dostuff 0.2.1__tar.gz → 0.2.2__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.
- {dostuff-0.2.1 → dostuff-0.2.2}/PKG-INFO +17 -13
- {dostuff-0.2.1 → dostuff-0.2.2}/README.md +15 -10
- dostuff-0.2.2/dostuff/helpers/mcp/token_store.py +81 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_client.py +90 -36
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/PKG-INFO +17 -13
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/requires.txt +1 -3
- {dostuff-0.2.1 → dostuff-0.2.2}/pyproject.toml +3 -5
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_mcp_oauth.py +110 -1
- dostuff-0.2.1/dostuff/helpers/mcp/token_store.py +0 -46
- {dostuff-0.2.1 → dostuff-0.2.2}/LICENSE +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/__init__.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/__main__.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/call_agent.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/instructions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/loop.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/permissions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/run_tool.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/cli.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/cli_tui.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/config.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/headless.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/append_step.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/constants.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/consume_stream.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/context_usage.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/environment_context.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/file_mentions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/get_model_token_limit.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/load_identity.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/load_project_instructions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/loop_check_cancelled.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/loop_emit.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/manage_context.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/model_cost.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/pastes.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/quick_memory.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/slash_suggestion.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/tool_output.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/load_mcp_config.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/mcp_oauth.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/server_config.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/export_session.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/resolve_memory_operation.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/shell_step.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/add_model_entry.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/list_models.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/opencode.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/custom_commands.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/discover_skills.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/skill_command.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/diff.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/generate_tool_schema.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/project_root.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/resolve_safe_path.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/__init__.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/doctor.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/emit.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/prompt_history.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/terminal_signals.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/checkpoints.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/exceptions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_client_registration_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_tool_registry_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/chroma.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/episodic_memory_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/semantic_memory_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/session_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/stores.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/types.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/model.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/tracing.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/worktree.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/memory/__init__.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/setup_wizard.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/skills.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/templates/config.example.yaml +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/templates/env.example +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/__init__.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/bash/bash_command.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/definitions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/delegate_to_subagent.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/delete_file.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/edit_file.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/list_files.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/read_file.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/write_file.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/get_current_datetime.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/call_mcp_tool.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/get_mcp_tool_details.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/search_mcp_tools.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/memory/save_episode.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/memory/save_memory.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/glob.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/grep.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/walk.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/settings.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/todo_write.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/web_fetch.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/web_search.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/SOURCES.txt +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/dependency_links.txt +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/entry_points.txt +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/top_level.txt +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/setup.cfg +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_checkpoints_todos_caching.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_cli_commands.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_config_memory_mcp.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_file_mentions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_file_tools.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_loop.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_mcp_e2e.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_opencode.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_optional_memory_and_instructions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_pastes.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_permissions.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_roadmap_021.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_session_store.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_settings_tools.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_setup_wizard.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_skill_command.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_slash_suggestion.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_subagent.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_terminal_signals.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_tool_schemas.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_tui.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_web_fetch.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_web_search.py +0 -0
- {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dostuff
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: DoStuff - a terminal coding agent for any LLM: TUI and headless mode, permissions, MCP, skills, web tools, sessions and git worktrees
|
|
5
5
|
Author-email: Varun Kumawat <varunkumawatleap2@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,6 +32,7 @@ Requires-Dist: opentelemetry-api>=1.20
|
|
|
32
32
|
Requires-Dist: opentelemetry-sdk>=1.20
|
|
33
33
|
Requires-Dist: textual>=0.50.0
|
|
34
34
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20
|
|
35
|
+
Requires-Dist: keyring>=24
|
|
35
36
|
Provides-Extra: memory
|
|
36
37
|
Requires-Dist: chromadb>=1.5.0; extra == "memory"
|
|
37
38
|
Provides-Extra: search
|
|
@@ -40,8 +41,6 @@ Provides-Extra: full
|
|
|
40
41
|
Requires-Dist: dostuff[memory,search]; extra == "full"
|
|
41
42
|
Requires-Dist: onnxruntime>=1.16; extra == "full"
|
|
42
43
|
Requires-Dist: grpcio>=1.52; extra == "full"
|
|
43
|
-
Provides-Extra: keyring
|
|
44
|
-
Requires-Dist: keyring>=24; extra == "keyring"
|
|
45
44
|
Provides-Extra: dev
|
|
46
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
47
46
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
@@ -110,15 +109,19 @@ pip install dostuff
|
|
|
110
109
|
|
|
111
110
|
This installs the `dostuff` command on your `PATH`. Done.
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
a large download, so it's an extra:
|
|
112
|
+
Optional features come as extras:
|
|
115
113
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
| Install | Adds |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `pip install dostuff` | Everything except the extras below, including MCP (with OAuth tokens kept in the OS keyring) |
|
|
117
|
+
| `pip install "dostuff[memory]"` | Long-term memory (facts and past decisions across sessions) and semantic MCP tool search, via ChromaDB. Large download |
|
|
118
|
+
| `pip install "dostuff[search]"` | Steadier `web_search` through the `ddgs` package (several engines) instead of DuckDuckGo's HTML page |
|
|
119
|
+
| `pip install "dostuff[full]"` | `memory` + `search` |
|
|
120
|
+
| `pip install "dostuff[dev]"` | `pytest` and `mypy`, for working on dostuff itself |
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
Extras combine, e.g. `pip install "dostuff[memory,search]"`. Without `memory` everything else
|
|
123
|
+
works: the memory tools are hidden from the model and MCP tool search matches keywords instead of
|
|
124
|
+
meaning. `dostuff doctor` shows whether memory is on.
|
|
122
125
|
|
|
123
126
|
### Other install methods
|
|
124
127
|
|
|
@@ -126,6 +129,7 @@ For an isolated install with [pipx](https://pypa.github.io/pipx/):
|
|
|
126
129
|
|
|
127
130
|
```bash
|
|
128
131
|
pipx install dostuff
|
|
132
|
+
pipx install "dostuff[full]" # with extras
|
|
129
133
|
```
|
|
130
134
|
|
|
131
135
|
For development (editable mode, from a clone):
|
|
@@ -135,8 +139,8 @@ git clone https://github.com/kVarunkk/DoStuff.git
|
|
|
135
139
|
cd DoStuff
|
|
136
140
|
pip install -e . # core
|
|
137
141
|
pip install -e ".[memory]" # + long-term memory (chromadb)
|
|
138
|
-
pip install -e ".[
|
|
139
|
-
pip install -e ".[
|
|
142
|
+
pip install -e ".[search]" # + ddgs web search
|
|
143
|
+
pip install -e ".[full]" # + memory + search
|
|
140
144
|
pip install -e ".[dev]" # + pytest + mypy
|
|
141
145
|
|
|
142
146
|
python -m pytest -q tests
|
|
@@ -462,7 +466,7 @@ MCP servers connect concurrently in the background, so the TUI never blanks duri
|
|
|
462
466
|
|
|
463
467
|
MCP tools are named `<server>__<tool>` (e.g. `github__create_issue`), so two servers can expose a tool with the same name. `search_mcp_tools` only returns tools of servers that are connected right now.
|
|
464
468
|
|
|
465
|
-
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are
|
|
469
|
+
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are stored in the OS keyring (memory only if no keyring backend is available), never in a plain file. Expired access tokens are refreshed with the refresh token, before a request or after a 401, without opening the browser; you sign in again only when the refresh token itself is rejected.
|
|
466
470
|
|
|
467
471
|
To force a re-registration, delete:
|
|
468
472
|
|
|
@@ -61,15 +61,19 @@ pip install dostuff
|
|
|
61
61
|
|
|
62
62
|
This installs the `dostuff` command on your `PATH`. Done.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
a large download, so it's an extra:
|
|
64
|
+
Optional features come as extras:
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
| Install | Adds |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `pip install dostuff` | Everything except the extras below, including MCP (with OAuth tokens kept in the OS keyring) |
|
|
69
|
+
| `pip install "dostuff[memory]"` | Long-term memory (facts and past decisions across sessions) and semantic MCP tool search, via ChromaDB. Large download |
|
|
70
|
+
| `pip install "dostuff[search]"` | Steadier `web_search` through the `ddgs` package (several engines) instead of DuckDuckGo's HTML page |
|
|
71
|
+
| `pip install "dostuff[full]"` | `memory` + `search` |
|
|
72
|
+
| `pip install "dostuff[dev]"` | `pytest` and `mypy`, for working on dostuff itself |
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
Extras combine, e.g. `pip install "dostuff[memory,search]"`. Without `memory` everything else
|
|
75
|
+
works: the memory tools are hidden from the model and MCP tool search matches keywords instead of
|
|
76
|
+
meaning. `dostuff doctor` shows whether memory is on.
|
|
73
77
|
|
|
74
78
|
### Other install methods
|
|
75
79
|
|
|
@@ -77,6 +81,7 @@ For an isolated install with [pipx](https://pypa.github.io/pipx/):
|
|
|
77
81
|
|
|
78
82
|
```bash
|
|
79
83
|
pipx install dostuff
|
|
84
|
+
pipx install "dostuff[full]" # with extras
|
|
80
85
|
```
|
|
81
86
|
|
|
82
87
|
For development (editable mode, from a clone):
|
|
@@ -86,8 +91,8 @@ git clone https://github.com/kVarunkk/DoStuff.git
|
|
|
86
91
|
cd DoStuff
|
|
87
92
|
pip install -e . # core
|
|
88
93
|
pip install -e ".[memory]" # + long-term memory (chromadb)
|
|
89
|
-
pip install -e ".[
|
|
90
|
-
pip install -e ".[
|
|
94
|
+
pip install -e ".[search]" # + ddgs web search
|
|
95
|
+
pip install -e ".[full]" # + memory + search
|
|
91
96
|
pip install -e ".[dev]" # + pytest + mypy
|
|
92
97
|
|
|
93
98
|
python -m pytest -q tests
|
|
@@ -413,7 +418,7 @@ MCP servers connect concurrently in the background, so the TUI never blanks duri
|
|
|
413
418
|
|
|
414
419
|
MCP tools are named `<server>__<tool>` (e.g. `github__create_issue`), so two servers can expose a tool with the same name. `search_mcp_tools` only returns tools of servers that are connected right now.
|
|
415
420
|
|
|
416
|
-
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are
|
|
421
|
+
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are stored in the OS keyring (memory only if no keyring backend is available), never in a plain file. Expired access tokens are refreshed with the refresh token, before a request or after a 401, without opening the browser; you sign in again only when the refresh token itself is rejected.
|
|
417
422
|
|
|
418
423
|
To force a re-registration, delete:
|
|
419
424
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""OAuth token persistence in the OS keyring.
|
|
2
|
+
|
|
3
|
+
If `keyring` can't be imported or has no usable backend, tokens live in memory only; they are
|
|
4
|
+
never written to a plaintext file.
|
|
5
|
+
|
|
6
|
+
Windows Credential Manager caps a secret at 2560 bytes (1280 UTF-16 chars) and JWT access plus
|
|
7
|
+
refresh tokens easily exceed that, so long values are split across `<url>#<i>` entries and the
|
|
8
|
+
main entry holds `chunks:<n>`.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
SERVICE = "dostuff-mcp"
|
|
14
|
+
CHUNK_CHARS = 1000
|
|
15
|
+
_CHUNK_PREFIX = "chunks:"
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import keyring as _keyring
|
|
19
|
+
except ImportError: # a required dependency, but don't crash a broken install
|
|
20
|
+
_keyring = None # type: ignore[assignment]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def keyring_available() -> bool:
|
|
24
|
+
return _keyring is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _chunk_count(head: str | None) -> int:
|
|
28
|
+
if head and head.startswith(_CHUNK_PREFIX):
|
|
29
|
+
try:
|
|
30
|
+
return int(head[len(_CHUNK_PREFIX):])
|
|
31
|
+
except ValueError:
|
|
32
|
+
return 0
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_token(server_url: str) -> dict[str, Any] | None:
|
|
37
|
+
if _keyring is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
raw = _keyring.get_password(SERVICE, server_url)
|
|
41
|
+
if n := _chunk_count(raw):
|
|
42
|
+
parts = [_keyring.get_password(SERVICE, f"{server_url}#{i}") for i in range(n)]
|
|
43
|
+
chunks = [p for p in parts if p is not None]
|
|
44
|
+
raw = "".join(chunks) if len(chunks) == n else None
|
|
45
|
+
data = json.loads(raw) if raw else None
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
return data if isinstance(data, dict) and data.get("access_token") else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_token(server_url: str, token: dict[str, Any]) -> bool:
|
|
52
|
+
"""True when the token reached the keyring."""
|
|
53
|
+
if _keyring is None:
|
|
54
|
+
return False
|
|
55
|
+
raw = json.dumps(token)
|
|
56
|
+
try:
|
|
57
|
+
delete_token(server_url)
|
|
58
|
+
if len(raw) <= CHUNK_CHARS:
|
|
59
|
+
_keyring.set_password(SERVICE, server_url, raw)
|
|
60
|
+
return True
|
|
61
|
+
parts = [raw[i:i + CHUNK_CHARS] for i in range(0, len(raw), CHUNK_CHARS)]
|
|
62
|
+
for i, part in enumerate(parts):
|
|
63
|
+
_keyring.set_password(SERVICE, f"{server_url}#{i}", part)
|
|
64
|
+
_keyring.set_password(SERVICE, server_url, f"{_CHUNK_PREFIX}{len(parts)}") # last: marks it complete
|
|
65
|
+
return True
|
|
66
|
+
except Exception:
|
|
67
|
+
return False # no usable keyring backend; keep the in-memory token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def delete_token(server_url: str) -> None:
|
|
71
|
+
if _keyring is None:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
n = _chunk_count(_keyring.get_password(SERVICE, server_url))
|
|
75
|
+
except Exception:
|
|
76
|
+
n = 0
|
|
77
|
+
for name in [server_url, *(f"{server_url}#{i}" for i in range(n))]:
|
|
78
|
+
try:
|
|
79
|
+
_keyring.delete_password(SERVICE, name)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
@@ -46,12 +46,35 @@ class _ServerConnection:
|
|
|
46
46
|
self.stop = asyncio.Event()
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
class _OAuthBearer(httpx2.Auth):
|
|
50
|
+
"""Puts a current access token on every request and refreshes it (no browser) when it is
|
|
51
|
+
about to expire or the server answers 401, so a session outlives its first token."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: "MCPClient", server_name: str, url: str) -> None:
|
|
54
|
+
self._client = client
|
|
55
|
+
self._server_name = server_name
|
|
56
|
+
self._url = url
|
|
57
|
+
|
|
58
|
+
async def async_auth_flow(self, request):
|
|
59
|
+
token = await self._client._valid_token(self._server_name, self._url)
|
|
60
|
+
if token:
|
|
61
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
62
|
+
response = yield request
|
|
63
|
+
if response.status_code == 401:
|
|
64
|
+
fresh = await self._client._refresh(self._server_name, self._url, rejected=token)
|
|
65
|
+
if fresh:
|
|
66
|
+
request.headers["Authorization"] = f"Bearer {fresh}"
|
|
67
|
+
yield request
|
|
68
|
+
|
|
69
|
+
|
|
49
70
|
class MCPClient:
|
|
50
71
|
def __init__(self, registration_store: MCPClientRegistrationStore, tool_registry_store: MCPToolRegistryStore | None):
|
|
51
72
|
self.servers: dict[str, ClientSession] = {}
|
|
52
73
|
# server__tool -> {"server_name", "tool_name", "schema"}
|
|
53
74
|
self.mcp_tools: dict[str, dict[str, Any]] = {}
|
|
54
75
|
self._tokens: dict[str, dict] = {}
|
|
76
|
+
self._refresh_locks: dict[str, asyncio.Lock] = {}
|
|
77
|
+
self._warned_unsaved = False
|
|
55
78
|
self.server_meta: dict[str, dict] = {}
|
|
56
79
|
self._connections: dict[str, _ServerConnection] = {}
|
|
57
80
|
self.registration_store = registration_store
|
|
@@ -86,20 +109,19 @@ class MCPClient:
|
|
|
86
109
|
"args": args,
|
|
87
110
|
"env": env,
|
|
88
111
|
}
|
|
112
|
+
auth: _OAuthBearer | None = None
|
|
89
113
|
if transport in HTTP_TRANSPORTS or url:
|
|
90
114
|
if not url:
|
|
91
115
|
raise ValueError(f"Server '{server_name}' requires a 'url' for transport '{transport}'.")
|
|
92
|
-
if "Authorization" not in req_headers:
|
|
93
|
-
|
|
94
|
-
if token:
|
|
95
|
-
req_headers["Authorization"] = f"Bearer {token}"
|
|
116
|
+
if "Authorization" not in req_headers and await self._get_or_refresh_token(server_name, url):
|
|
117
|
+
auth = _OAuthBearer(self, server_name, url)
|
|
96
118
|
elif not command:
|
|
97
119
|
raise ValueError(f"Server '{server_name}' requires a 'command' for stdio transport.")
|
|
98
120
|
|
|
99
121
|
conn = _ServerConnection()
|
|
100
122
|
ready: asyncio.Future[ClientSession] = asyncio.get_running_loop().create_future()
|
|
101
123
|
conn.task = asyncio.create_task(
|
|
102
|
-
self._serve(server_name, transport, url, req_headers, command, args, env, ready, conn.stop),
|
|
124
|
+
self._serve(server_name, transport, url, req_headers, auth, command, args, env, ready, conn.stop),
|
|
103
125
|
name=f"mcp:{server_name}",
|
|
104
126
|
)
|
|
105
127
|
self._connections[server_name] = conn
|
|
@@ -133,17 +155,17 @@ class MCPClient:
|
|
|
133
155
|
},
|
|
134
156
|
}
|
|
135
157
|
|
|
136
|
-
async def _serve(self, server_name, transport, url, req_headers, command, args, env, ready: asyncio.Future, stop: asyncio.Event) -> None:
|
|
158
|
+
async def _serve(self, server_name, transport, url, req_headers, auth, command, args, env, ready: asyncio.Future, stop: asyncio.Event) -> None:
|
|
137
159
|
"""Owns one server's transports: opens them, hands back the session, closes on stop."""
|
|
138
160
|
try:
|
|
139
161
|
async with AsyncExitStack() as stack:
|
|
140
162
|
if transport in HTTP_TRANSPORTS or url:
|
|
141
163
|
if transport in ("http", "streamable-http"):
|
|
142
|
-
http_client = await stack.enter_async_context(httpx2.AsyncClient(headers=req_headers, timeout=timeout, event_hooks={"response": [self._log_response]}))
|
|
164
|
+
http_client = await stack.enter_async_context(httpx2.AsyncClient(headers=req_headers, auth=auth, timeout=timeout, event_hooks={"response": [self._log_response]}))
|
|
143
165
|
self.server_meta[server_name]["http_client"] = http_client
|
|
144
166
|
read, write, *_ = await stack.enter_async_context(streamable_http_client(url=url, http_client=http_client))
|
|
145
167
|
else:
|
|
146
|
-
read, write = await stack.enter_async_context(sse_client(url=url, headers=req_headers))
|
|
168
|
+
read, write = await stack.enter_async_context(sse_client(url=url, headers=req_headers, auth=auth))
|
|
147
169
|
else:
|
|
148
170
|
merged_env = os.environ.copy()
|
|
149
171
|
if env:
|
|
@@ -188,12 +210,15 @@ class MCPClient:
|
|
|
188
210
|
await asyncio.gather(conn.task, return_exceptions=True)
|
|
189
211
|
|
|
190
212
|
async def reauthenticate(self, server_name: str) -> None:
|
|
191
|
-
"""
|
|
213
|
+
"""Reconnect with a new token: refreshed while the refresh token still works, otherwise
|
|
214
|
+
the cached token is dropped and the OAuth flow runs again."""
|
|
192
215
|
meta = self.server_meta.get(server_name, {})
|
|
193
216
|
url = meta.get("url")
|
|
194
|
-
self._tokens.pop(server_name, None)
|
|
195
217
|
if url:
|
|
196
|
-
|
|
218
|
+
cached = self._cached_token(server_name, url)
|
|
219
|
+
if await self._refresh(server_name, url, rejected=cached["access_token"] if cached else None) is None:
|
|
220
|
+
self._tokens.pop(server_name, None)
|
|
221
|
+
token_store.delete_token(url)
|
|
197
222
|
headers = dict(meta.get("headers") or {})
|
|
198
223
|
headers.pop("Authorization", None)
|
|
199
224
|
await self.connect_to_server(
|
|
@@ -209,30 +234,9 @@ class MCPClient:
|
|
|
209
234
|
async def _get_or_refresh_token(self, server_name: str, url: str) -> str | None:
|
|
210
235
|
"""Cached (memory, then keyring) token, refreshed if expired; else probe the server and
|
|
211
236
|
run the OAuth flow when it answers 401."""
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if time.time() < cached["expires_at"] - 60: # 60s safety margin
|
|
216
|
-
self._tokens[server_name] = cached
|
|
217
|
-
return cached["access_token"]
|
|
218
|
-
if cached.get("refresh_token"):
|
|
219
|
-
# A confidential client (e.g. Supabase) refreshes with the secret from its registration
|
|
220
|
-
registration = await self.registration_store.get(server_name) or {}
|
|
221
|
-
same_client = registration.get("client_id") == cached.get("client_id")
|
|
222
|
-
try:
|
|
223
|
-
token_data = await refresh_access_token(
|
|
224
|
-
cached["token_endpoint"], cached["refresh_token"],
|
|
225
|
-
client_id=cached.get("client_id"), resource=cached.get("resource"),
|
|
226
|
-
client_secret=registration.get("client_secret") if same_client else None,
|
|
227
|
-
auth_method=registration.get("token_endpoint_auth_method") if same_client else None,
|
|
228
|
-
)
|
|
229
|
-
token_data.setdefault("refresh_token", cached["refresh_token"])
|
|
230
|
-
token_data["client_id"] = cached.get("client_id")
|
|
231
|
-
token_data["resource"] = cached.get("resource")
|
|
232
|
-
self._store_token(server_name, url, cached["token_endpoint"], token_data)
|
|
233
|
-
return token_data["access_token"]
|
|
234
|
-
except httpx.HTTPError:
|
|
235
|
-
pass # refresh token expired/revoked: fall through to a fresh flow
|
|
237
|
+
token = await self._valid_token(server_name, url)
|
|
238
|
+
if token:
|
|
239
|
+
return token
|
|
236
240
|
|
|
237
241
|
try:
|
|
238
242
|
async with httpx.AsyncClient() as probe_client:
|
|
@@ -249,6 +253,52 @@ class MCPClient:
|
|
|
249
253
|
return token_data["access_token"]
|
|
250
254
|
return None
|
|
251
255
|
|
|
256
|
+
def _cached_token(self, server_name: str, url: str) -> dict | None:
|
|
257
|
+
cached = self._tokens.get(server_name) or token_store.load_token(url)
|
|
258
|
+
if cached:
|
|
259
|
+
self._tokens[server_name] = cached
|
|
260
|
+
return cached
|
|
261
|
+
|
|
262
|
+
async def _valid_token(self, server_name: str, url: str) -> str | None:
|
|
263
|
+
"""The cached access token, refreshed if it is about to expire. Never opens a browser."""
|
|
264
|
+
cached = self._cached_token(server_name, url)
|
|
265
|
+
if cached is None:
|
|
266
|
+
return None
|
|
267
|
+
if time.time() < cached["expires_at"] - 60: # 60s safety margin
|
|
268
|
+
return cached["access_token"]
|
|
269
|
+
return await self._refresh(server_name, url, rejected=cached["access_token"])
|
|
270
|
+
|
|
271
|
+
async def _refresh(self, server_name: str, url: str, rejected: str | None) -> str | None:
|
|
272
|
+
"""Trade the refresh token for a new access token; None when there is none or the server
|
|
273
|
+
refuses it. `rejected` is the token that just failed: if a concurrent request already
|
|
274
|
+
replaced it, the newer one is returned instead of refreshing twice."""
|
|
275
|
+
async with self._refresh_locks.setdefault(server_name, asyncio.Lock()):
|
|
276
|
+
cached = self._cached_token(server_name, url)
|
|
277
|
+
if cached is None:
|
|
278
|
+
return None
|
|
279
|
+
if cached["access_token"] != rejected and time.time() < cached["expires_at"] - 60:
|
|
280
|
+
return cached["access_token"]
|
|
281
|
+
if not cached.get("refresh_token"):
|
|
282
|
+
return None
|
|
283
|
+
# A confidential client (e.g. Supabase) refreshes with the secret from its registration
|
|
284
|
+
registration = await self.registration_store.get(server_name) or {}
|
|
285
|
+
same_client = registration.get("client_id") == cached.get("client_id")
|
|
286
|
+
try:
|
|
287
|
+
token_data = await refresh_access_token(
|
|
288
|
+
cached["token_endpoint"], cached["refresh_token"],
|
|
289
|
+
client_id=cached.get("client_id"), resource=cached.get("resource"),
|
|
290
|
+
client_secret=registration.get("client_secret") if same_client else None,
|
|
291
|
+
auth_method=registration.get("token_endpoint_auth_method") if same_client else None,
|
|
292
|
+
)
|
|
293
|
+
access_token = token_data["access_token"]
|
|
294
|
+
except (httpx.HTTPError, KeyError, ValueError):
|
|
295
|
+
return None # refresh token expired/revoked
|
|
296
|
+
token_data.setdefault("refresh_token", cached["refresh_token"])
|
|
297
|
+
token_data["client_id"] = cached.get("client_id")
|
|
298
|
+
token_data["resource"] = cached.get("resource")
|
|
299
|
+
self._store_token(server_name, url, cached["token_endpoint"], token_data)
|
|
300
|
+
return access_token
|
|
301
|
+
|
|
252
302
|
def _store_token(self, server_name: str, url: str, token_endpoint: str, token_data: dict) -> None:
|
|
253
303
|
token = {
|
|
254
304
|
"access_token": token_data["access_token"],
|
|
@@ -259,7 +309,11 @@ class MCPClient:
|
|
|
259
309
|
"resource": token_data.get("resource"),
|
|
260
310
|
}
|
|
261
311
|
self._tokens[server_name] = token
|
|
262
|
-
token_store.save_token(url, token)
|
|
312
|
+
if not token_store.save_token(url, token) and not self._warned_unsaved:
|
|
313
|
+
self._warned_unsaved = True
|
|
314
|
+
from dostuff.helpers.ui.emit import emit
|
|
315
|
+
why = "the OS keyring refused it" if token_store.keyring_available() else "`keyring` isn't importable; reinstall dostuff"
|
|
316
|
+
emit(f"MCP sign-in for '{server_name}' is kept in memory only ({why}); you will sign in again next start.", msg_type="system")
|
|
263
317
|
|
|
264
318
|
async def cleanup(self) -> None:
|
|
265
319
|
"""Close every server connection (each in the task that opened it)."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dostuff
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: DoStuff - a terminal coding agent for any LLM: TUI and headless mode, permissions, MCP, skills, web tools, sessions and git worktrees
|
|
5
5
|
Author-email: Varun Kumawat <varunkumawatleap2@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,6 +32,7 @@ Requires-Dist: opentelemetry-api>=1.20
|
|
|
32
32
|
Requires-Dist: opentelemetry-sdk>=1.20
|
|
33
33
|
Requires-Dist: textual>=0.50.0
|
|
34
34
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20
|
|
35
|
+
Requires-Dist: keyring>=24
|
|
35
36
|
Provides-Extra: memory
|
|
36
37
|
Requires-Dist: chromadb>=1.5.0; extra == "memory"
|
|
37
38
|
Provides-Extra: search
|
|
@@ -40,8 +41,6 @@ Provides-Extra: full
|
|
|
40
41
|
Requires-Dist: dostuff[memory,search]; extra == "full"
|
|
41
42
|
Requires-Dist: onnxruntime>=1.16; extra == "full"
|
|
42
43
|
Requires-Dist: grpcio>=1.52; extra == "full"
|
|
43
|
-
Provides-Extra: keyring
|
|
44
|
-
Requires-Dist: keyring>=24; extra == "keyring"
|
|
45
44
|
Provides-Extra: dev
|
|
46
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
47
46
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
@@ -110,15 +109,19 @@ pip install dostuff
|
|
|
110
109
|
|
|
111
110
|
This installs the `dostuff` command on your `PATH`. Done.
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
a large download, so it's an extra:
|
|
112
|
+
Optional features come as extras:
|
|
115
113
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
| Install | Adds |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `pip install dostuff` | Everything except the extras below, including MCP (with OAuth tokens kept in the OS keyring) |
|
|
117
|
+
| `pip install "dostuff[memory]"` | Long-term memory (facts and past decisions across sessions) and semantic MCP tool search, via ChromaDB. Large download |
|
|
118
|
+
| `pip install "dostuff[search]"` | Steadier `web_search` through the `ddgs` package (several engines) instead of DuckDuckGo's HTML page |
|
|
119
|
+
| `pip install "dostuff[full]"` | `memory` + `search` |
|
|
120
|
+
| `pip install "dostuff[dev]"` | `pytest` and `mypy`, for working on dostuff itself |
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
Extras combine, e.g. `pip install "dostuff[memory,search]"`. Without `memory` everything else
|
|
123
|
+
works: the memory tools are hidden from the model and MCP tool search matches keywords instead of
|
|
124
|
+
meaning. `dostuff doctor` shows whether memory is on.
|
|
122
125
|
|
|
123
126
|
### Other install methods
|
|
124
127
|
|
|
@@ -126,6 +129,7 @@ For an isolated install with [pipx](https://pypa.github.io/pipx/):
|
|
|
126
129
|
|
|
127
130
|
```bash
|
|
128
131
|
pipx install dostuff
|
|
132
|
+
pipx install "dostuff[full]" # with extras
|
|
129
133
|
```
|
|
130
134
|
|
|
131
135
|
For development (editable mode, from a clone):
|
|
@@ -135,8 +139,8 @@ git clone https://github.com/kVarunkk/DoStuff.git
|
|
|
135
139
|
cd DoStuff
|
|
136
140
|
pip install -e . # core
|
|
137
141
|
pip install -e ".[memory]" # + long-term memory (chromadb)
|
|
138
|
-
pip install -e ".[
|
|
139
|
-
pip install -e ".[
|
|
142
|
+
pip install -e ".[search]" # + ddgs web search
|
|
143
|
+
pip install -e ".[full]" # + memory + search
|
|
140
144
|
pip install -e ".[dev]" # + pytest + mypy
|
|
141
145
|
|
|
142
146
|
python -m pytest -q tests
|
|
@@ -462,7 +466,7 @@ MCP servers connect concurrently in the background, so the TUI never blanks duri
|
|
|
462
466
|
|
|
463
467
|
MCP tools are named `<server>__<tool>` (e.g. `github__create_issue`), so two servers can expose a tool with the same name. `search_mcp_tools` only returns tools of servers that are connected right now.
|
|
464
468
|
|
|
465
|
-
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are
|
|
469
|
+
**OAuth (HTTP servers):** a browser login opens when the server answers 401. The flow uses PKCE, a random `state` and an RFC 8707 `resource` indicator; the local callback on port 8085 times out after 120 s. Tokens are stored in the OS keyring (memory only if no keyring backend is available), never in a plain file. Expired access tokens are refreshed with the refresh token, before a request or after a 401, without opening the browser; you sign in again only when the refresh token itself is rejected.
|
|
466
470
|
|
|
467
471
|
To force a re-registration, delete:
|
|
468
472
|
|
|
@@ -12,6 +12,7 @@ opentelemetry-api>=1.20
|
|
|
12
12
|
opentelemetry-sdk>=1.20
|
|
13
13
|
textual>=0.50.0
|
|
14
14
|
opentelemetry-exporter-otlp-proto-grpc>=1.20
|
|
15
|
+
keyring>=24
|
|
15
16
|
|
|
16
17
|
[dev]
|
|
17
18
|
pytest>=8
|
|
@@ -22,9 +23,6 @@ dostuff[memory,search]
|
|
|
22
23
|
onnxruntime>=1.16
|
|
23
24
|
grpcio>=1.52
|
|
24
25
|
|
|
25
|
-
[keyring]
|
|
26
|
-
keyring>=24
|
|
27
|
-
|
|
28
26
|
[memory]
|
|
29
27
|
chromadb>=1.5.0
|
|
30
28
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dostuff"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "DoStuff - a terminal coding agent for any LLM: TUI and headless mode, permissions, MCP, skills, web tools, sessions and git worktrees"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -38,6 +38,8 @@ dependencies = [
|
|
|
38
38
|
"opentelemetry-sdk>=1.20",
|
|
39
39
|
"textual>=0.50.0",
|
|
40
40
|
"opentelemetry-exporter-otlp-proto-grpc>=1.20",
|
|
41
|
+
# MCP OAuth tokens persist in the OS keyring; without it every start opens the browser
|
|
42
|
+
"keyring>=24",
|
|
41
43
|
]
|
|
42
44
|
|
|
43
45
|
[project.urls]
|
|
@@ -63,10 +65,6 @@ full = [
|
|
|
63
65
|
"onnxruntime>=1.16",
|
|
64
66
|
"grpcio>=1.52",
|
|
65
67
|
]
|
|
66
|
-
# Persist MCP OAuth tokens in the OS keyring (otherwise they live in memory only)
|
|
67
|
-
keyring = [
|
|
68
|
-
"keyring>=24",
|
|
69
|
-
]
|
|
70
68
|
dev = [
|
|
71
69
|
"pytest>=8",
|
|
72
70
|
"mypy>=1.10",
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import base64
|
|
4
4
|
import json
|
|
5
|
+
import time
|
|
5
6
|
import urllib.parse
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
9
|
+
import httpx2
|
|
8
10
|
import pytest
|
|
9
11
|
|
|
10
|
-
from dostuff.helpers.mcp import mcp_oauth
|
|
12
|
+
from dostuff.helpers.mcp import mcp_oauth, token_store
|
|
11
13
|
from dostuff.helpers.mcp.mcp_oauth import OAuthError, authenticate_via_oauth, refresh_access_token, with_client_auth
|
|
14
|
+
from dostuff.lib.mcp import mcp_client as mcp_client_mod
|
|
15
|
+
from dostuff.lib.mcp.mcp_client import MCPClient, _OAuthBearer
|
|
12
16
|
from dostuff.lib.mcp.mcp_client_registration_store import MCPClientRegistrationStore
|
|
13
17
|
|
|
14
18
|
MCP_URL = "https://mcp.example.com/mcp"
|
|
@@ -113,3 +117,108 @@ def test_refresh_sends_the_secret(oauth_env):
|
|
|
113
117
|
def test_public_client_sends_no_secret():
|
|
114
118
|
body, auth = with_client_auth({"client_id": "c"}, None, None)
|
|
115
119
|
assert body == {"client_id": "c"} and type(auth) is httpx.Auth
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- token persistence and refresh ---------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class FakeKeyring:
|
|
125
|
+
"""Like Windows Credential Manager: refuses secrets over 1280 chars."""
|
|
126
|
+
def __init__(self):
|
|
127
|
+
self.items: dict[tuple[str, str], str] = {}
|
|
128
|
+
|
|
129
|
+
def get_password(self, service, name):
|
|
130
|
+
return self.items.get((service, name))
|
|
131
|
+
|
|
132
|
+
def set_password(self, service, name, value):
|
|
133
|
+
if len(value) > 1280:
|
|
134
|
+
raise OSError("The stub received bad data.")
|
|
135
|
+
self.items[(service, name)] = value
|
|
136
|
+
|
|
137
|
+
def delete_password(self, service, name):
|
|
138
|
+
if (service, name) not in self.items:
|
|
139
|
+
raise KeyError(name)
|
|
140
|
+
del self.items[(service, name)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def fake_keyring(monkeypatch):
|
|
145
|
+
ring = FakeKeyring()
|
|
146
|
+
monkeypatch.setattr(token_store, "_keyring", ring)
|
|
147
|
+
return ring
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_long_token_is_split_across_entries(fake_keyring):
|
|
151
|
+
token = {"access_token": "a" * 3000, "refresh_token": "r" * 1500}
|
|
152
|
+
assert token_store.save_token(MCP_URL, token)
|
|
153
|
+
assert token_store.load_token(MCP_URL) == token
|
|
154
|
+
assert len(fake_keyring.items) > 1
|
|
155
|
+
token_store.save_token(MCP_URL, {"access_token": "short"}) # replaces the chunks
|
|
156
|
+
assert token_store.load_token(MCP_URL) == {"access_token": "short"} and len(fake_keyring.items) == 1
|
|
157
|
+
token_store.delete_token(MCP_URL)
|
|
158
|
+
assert fake_keyring.items == {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def make_client(tmp_path, monkeypatch, access_token="old", expires_in=3600, refresh=None):
|
|
162
|
+
monkeypatch.setattr(token_store, "_keyring", FakeKeyring())
|
|
163
|
+
client = MCPClient(MCPClientRegistrationStore(path=str(tmp_path / "reg.json")), None)
|
|
164
|
+
client._tokens["srv"] = {
|
|
165
|
+
"access_token": access_token, "refresh_token": "ref", "expires_at": time.time() + expires_in,
|
|
166
|
+
"token_endpoint": f"{AS}/token", "client_id": "c", "resource": None,
|
|
167
|
+
}
|
|
168
|
+
calls = []
|
|
169
|
+
|
|
170
|
+
async def fake_refresh(endpoint, refresh_token, **kw):
|
|
171
|
+
calls.append(refresh_token)
|
|
172
|
+
if refresh is None:
|
|
173
|
+
return {"access_token": "new", "expires_in": 3600}
|
|
174
|
+
raise refresh
|
|
175
|
+
|
|
176
|
+
async def no_browser(*a, **kw):
|
|
177
|
+
raise AssertionError("the browser flow ran")
|
|
178
|
+
monkeypatch.setattr(mcp_client_mod, "refresh_access_token", fake_refresh)
|
|
179
|
+
monkeypatch.setattr(mcp_client_mod, "authenticate_via_oauth", no_browser)
|
|
180
|
+
return client, calls
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_expired_token_is_refreshed_without_a_browser(tmp_path, monkeypatch):
|
|
184
|
+
client, calls = make_client(tmp_path, monkeypatch, expires_in=-10)
|
|
185
|
+
assert asyncio.run(client._get_or_refresh_token("srv", MCP_URL)) == "new"
|
|
186
|
+
assert calls == ["ref"] and token_store.load_token(MCP_URL)["refresh_token"] == "ref"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_401_mid_session_refreshes_and_retries(tmp_path, monkeypatch):
|
|
190
|
+
client, calls = make_client(tmp_path, monkeypatch)
|
|
191
|
+
seen = []
|
|
192
|
+
|
|
193
|
+
def handler(request):
|
|
194
|
+
seen.append(request.headers["authorization"])
|
|
195
|
+
return httpx2.Response(200 if request.headers["authorization"] == "Bearer new" else 401)
|
|
196
|
+
|
|
197
|
+
async def go():
|
|
198
|
+
async with httpx2.AsyncClient(auth=_OAuthBearer(client, "srv", MCP_URL), transport=httpx2.MockTransport(handler)) as http:
|
|
199
|
+
return (await http.post(MCP_URL, json={})).status_code
|
|
200
|
+
assert asyncio.run(go()) == 200
|
|
201
|
+
assert seen == ["Bearer old", "Bearer new"] and calls == ["ref"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_reauthenticate_refreshes_before_signing_in_again(tmp_path, monkeypatch):
|
|
205
|
+
client, calls = make_client(tmp_path, monkeypatch)
|
|
206
|
+
client.server_meta["srv"] = {"url": MCP_URL, "transport": "streamable-http", "headers": {}}
|
|
207
|
+
reconnects = []
|
|
208
|
+
|
|
209
|
+
async def fake_connect(**kw):
|
|
210
|
+
reconnects.append(kw["server_name"])
|
|
211
|
+
monkeypatch.setattr(client, "connect_to_server", fake_connect)
|
|
212
|
+
asyncio.run(client.reauthenticate("srv"))
|
|
213
|
+
assert calls == ["ref"] and client._tokens["srv"]["access_token"] == "new" and reconnects == ["srv"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_reauthenticate_drops_a_dead_refresh_token(tmp_path, monkeypatch):
|
|
217
|
+
client, calls = make_client(tmp_path, monkeypatch, refresh=httpx.HTTPError("invalid_grant"))
|
|
218
|
+
client.server_meta["srv"] = {"url": MCP_URL, "transport": "streamable-http", "headers": {}}
|
|
219
|
+
|
|
220
|
+
async def fake_connect(**kw):
|
|
221
|
+
pass
|
|
222
|
+
monkeypatch.setattr(client, "connect_to_server", fake_connect)
|
|
223
|
+
asyncio.run(client.reauthenticate("srv"))
|
|
224
|
+
assert "srv" not in client._tokens
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Optional OAuth token persistence in the OS keyring (`pip install dostuff[keyring]`).
|
|
2
|
-
|
|
3
|
-
Without `keyring`, tokens live in memory only; they are never written to a plaintext file.
|
|
4
|
-
"""
|
|
5
|
-
import json
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
SERVICE = "dostuff-mcp"
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
import keyring as _keyring
|
|
12
|
-
except ImportError: # optional dependency
|
|
13
|
-
_keyring = None
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def keyring_available() -> bool:
|
|
17
|
-
return _keyring is not None
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def load_token(server_url: str) -> dict[str, Any] | None:
|
|
21
|
-
if _keyring is None:
|
|
22
|
-
return None
|
|
23
|
-
try:
|
|
24
|
-
raw = _keyring.get_password(SERVICE, server_url)
|
|
25
|
-
data = json.loads(raw) if raw else None
|
|
26
|
-
except Exception:
|
|
27
|
-
return None
|
|
28
|
-
return data if isinstance(data, dict) and data.get("access_token") else None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def save_token(server_url: str, token: dict[str, Any]) -> None:
|
|
32
|
-
if _keyring is None:
|
|
33
|
-
return
|
|
34
|
-
try:
|
|
35
|
-
_keyring.set_password(SERVICE, server_url, json.dumps(token))
|
|
36
|
-
except Exception:
|
|
37
|
-
pass # no usable keyring backend; keep the in-memory token
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def delete_token(server_url: str) -> None:
|
|
41
|
-
if _keyring is None:
|
|
42
|
-
return
|
|
43
|
-
try:
|
|
44
|
-
_keyring.delete_password(SERVICE, server_url)
|
|
45
|
-
except Exception:
|
|
46
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|