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.
Files changed (128) hide show
  1. {dostuff-0.2.1 → dostuff-0.2.2}/PKG-INFO +17 -13
  2. {dostuff-0.2.1 → dostuff-0.2.2}/README.md +15 -10
  3. dostuff-0.2.2/dostuff/helpers/mcp/token_store.py +81 -0
  4. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_client.py +90 -36
  5. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/PKG-INFO +17 -13
  6. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/requires.txt +1 -3
  7. {dostuff-0.2.1 → dostuff-0.2.2}/pyproject.toml +3 -5
  8. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_mcp_oauth.py +110 -1
  9. dostuff-0.2.1/dostuff/helpers/mcp/token_store.py +0 -46
  10. {dostuff-0.2.1 → dostuff-0.2.2}/LICENSE +0 -0
  11. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/__init__.py +0 -0
  12. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/__main__.py +0 -0
  13. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/call_agent.py +0 -0
  14. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/instructions.py +0 -0
  15. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/loop.py +0 -0
  16. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/permissions.py +0 -0
  17. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/agent/run_tool.py +0 -0
  18. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/cli.py +0 -0
  19. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/cli_tui.py +0 -0
  20. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/config.py +0 -0
  21. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/headless.py +0 -0
  22. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/append_step.py +0 -0
  23. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/constants.py +0 -0
  24. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/consume_stream.py +0 -0
  25. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/context_usage.py +0 -0
  26. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/environment_context.py +0 -0
  27. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/file_mentions.py +0 -0
  28. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/get_model_token_limit.py +0 -0
  29. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/load_identity.py +0 -0
  30. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/load_project_instructions.py +0 -0
  31. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/loop_check_cancelled.py +0 -0
  32. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/loop_emit.py +0 -0
  33. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/manage_context.py +0 -0
  34. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/model_cost.py +0 -0
  35. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/pastes.py +0 -0
  36. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/quick_memory.py +0 -0
  37. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/slash_suggestion.py +0 -0
  38. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/agent/tool_output.py +0 -0
  39. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/load_mcp_config.py +0 -0
  40. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/mcp_oauth.py +0 -0
  41. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/mcp/server_config.py +0 -0
  42. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/export_session.py +0 -0
  43. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/resolve_memory_operation.py +0 -0
  44. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/memory/shell_step.py +0 -0
  45. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/add_model_entry.py +0 -0
  46. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/list_models.py +0 -0
  47. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/models/opencode.py +0 -0
  48. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/custom_commands.py +0 -0
  49. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/discover_skills.py +0 -0
  50. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/skills/skill_command.py +0 -0
  51. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/diff.py +0 -0
  52. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/generate_tool_schema.py +0 -0
  53. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/project_root.py +0 -0
  54. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/tools/resolve_safe_path.py +0 -0
  55. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/__init__.py +0 -0
  56. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/doctor.py +0 -0
  57. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/emit.py +0 -0
  58. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/prompt_history.py +0 -0
  59. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/helpers/ui/terminal_signals.py +0 -0
  60. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/checkpoints.py +0 -0
  61. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/exceptions.py +0 -0
  62. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_client_registration_store.py +0 -0
  63. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/mcp/mcp_tool_registry_store.py +0 -0
  64. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/chroma.py +0 -0
  65. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/episodic_memory_store.py +0 -0
  66. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/semantic_memory_store.py +0 -0
  67. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/session_store.py +0 -0
  68. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/stores.py +0 -0
  69. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/memory/types.py +0 -0
  70. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/model.py +0 -0
  71. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/tracing.py +0 -0
  72. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/lib/worktree.py +0 -0
  73. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/memory/__init__.py +0 -0
  74. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/setup_wizard.py +0 -0
  75. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/skills.py +0 -0
  76. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/templates/config.example.yaml +0 -0
  77. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/templates/env.example +0 -0
  78. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/__init__.py +0 -0
  79. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/bash/bash_command.py +0 -0
  80. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/definitions.py +0 -0
  81. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/delegate_to_subagent.py +0 -0
  82. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/delete_file.py +0 -0
  83. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/edit_file.py +0 -0
  84. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/list_files.py +0 -0
  85. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/read_file.py +0 -0
  86. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/files/write_file.py +0 -0
  87. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/get_current_datetime.py +0 -0
  88. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/call_mcp_tool.py +0 -0
  89. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/get_mcp_tool_details.py +0 -0
  90. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/mcp/search_mcp_tools.py +0 -0
  91. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/memory/save_episode.py +0 -0
  92. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/memory/save_memory.py +0 -0
  93. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/glob.py +0 -0
  94. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/grep.py +0 -0
  95. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/search/walk.py +0 -0
  96. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/settings.py +0 -0
  97. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/todo_write.py +0 -0
  98. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/web_fetch.py +0 -0
  99. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff/tools/web_search.py +0 -0
  100. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/SOURCES.txt +0 -0
  101. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/dependency_links.txt +0 -0
  102. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/entry_points.txt +0 -0
  103. {dostuff-0.2.1 → dostuff-0.2.2}/dostuff.egg-info/top_level.txt +0 -0
  104. {dostuff-0.2.1 → dostuff-0.2.2}/setup.cfg +0 -0
  105. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_checkpoints_todos_caching.py +0 -0
  106. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_cli_commands.py +0 -0
  107. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_config_memory_mcp.py +0 -0
  108. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_file_mentions.py +0 -0
  109. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_file_tools.py +0 -0
  110. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_loop.py +0 -0
  111. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_mcp_e2e.py +0 -0
  112. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_opencode.py +0 -0
  113. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_optional_memory_and_instructions.py +0 -0
  114. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_pastes.py +0 -0
  115. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_permissions.py +0 -0
  116. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_roadmap_021.py +0 -0
  117. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_session_store.py +0 -0
  118. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_settings_tools.py +0 -0
  119. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_setup_wizard.py +0 -0
  120. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_skill_command.py +0 -0
  121. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_slash_suggestion.py +0 -0
  122. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_subagent.py +0 -0
  123. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_terminal_signals.py +0 -0
  124. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_tool_schemas.py +0 -0
  125. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_tui.py +0 -0
  126. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_web_fetch.py +0 -0
  127. {dostuff-0.2.1 → dostuff-0.2.2}/tests/test_web_search.py +0 -0
  128. {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.1
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
- Long-term memory (the agent remembers facts and past decisions across sessions) needs ChromaDB,
114
- a large download, so it's an extra:
112
+ Optional features come as extras:
115
113
 
116
- ```bash
117
- pip install "dostuff[memory]"
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
- Without it everything else works: the memory tools are hidden from the model and MCP tool search
121
- matches keywords instead of meaning. `dostuff doctor` shows whether memory is on.
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 ".[full]" # + memory + onnxruntime + grpcio
139
- pip install -e ".[keyring]" # persist MCP OAuth tokens in the OS keyring
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 kept in memory, or in the OS keyring if you install `dostuff[keyring]`, never in a plain file. A tool call that fails with 401 re-authenticates once and retries.
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
- Long-term memory (the agent remembers facts and past decisions across sessions) needs ChromaDB,
65
- a large download, so it's an extra:
64
+ Optional features come as extras:
66
65
 
67
- ```bash
68
- pip install "dostuff[memory]"
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
- Without it everything else works: the memory tools are hidden from the model and MCP tool search
72
- matches keywords instead of meaning. `dostuff doctor` shows whether memory is on.
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 ".[full]" # + memory + onnxruntime + grpcio
90
- pip install -e ".[keyring]" # persist MCP OAuth tokens in the OS keyring
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 kept in memory, or in the OS keyring if you install `dostuff[keyring]`, never in a plain file. A tool call that fails with 401 re-authenticates once and retries.
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
- token = await self._get_or_refresh_token(server_name, url)
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
- """Drop the cached token and reconnect (runs the OAuth flow again if needed)."""
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
- token_store.delete_token(url)
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
- cached = self._tokens.get(server_name) or token_store.load_token(url)
213
-
214
- if cached:
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.1
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
- Long-term memory (the agent remembers facts and past decisions across sessions) needs ChromaDB,
114
- a large download, so it's an extra:
112
+ Optional features come as extras:
115
113
 
116
- ```bash
117
- pip install "dostuff[memory]"
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
- Without it everything else works: the memory tools are hidden from the model and MCP tool search
121
- matches keywords instead of meaning. `dostuff doctor` shows whether memory is on.
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 ".[full]" # + memory + onnxruntime + grpcio
139
- pip install -e ".[keyring]" # persist MCP OAuth tokens in the OS keyring
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 kept in memory, or in the OS keyring if you install `dostuff[keyring]`, never in a plain file. A tool call that fails with 401 re-authenticates once and retries.
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.1"
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