gopher-mcp-python 0.1.34__tar.gz → 0.1.39__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.
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/PKG-INFO +41 -1
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/README.md +40 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/__init__.py +13 -1
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/agent.py +48 -7
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/config.py +24 -12
- gopher_mcp_python-0.1.39/gopher_mcp_python/elicitation.py +120 -0
- gopher_mcp_python-0.1.39/gopher_mcp_python/elicitation_runtime.py +229 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/library.py +289 -24
- gopher_mcp_python-0.1.39/gopher_mcp_python/gateway_elicitation_preflight.py +326 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_resolver.py +1 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_runtime_options.py +1 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/runtime_options.py +55 -8
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/PKG-INFO +41 -1
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/SOURCES.txt +10 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/pyproject.toml +1 -1
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_create_with_oauth.py +70 -9
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_runtime_options.py +10 -1
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_config.py +21 -0
- gopher_mcp_python-0.1.39/tests/test_custom_oauth_test_idp.py +186 -0
- gopher_mcp_python-0.1.39/tests/test_custom_protected_mcp_endpoints.py +173 -0
- gopher_mcp_python-0.1.39/tests/test_elicitation_runtime.py +157 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_ffi_runtime_options.py +106 -0
- gopher_mcp_python-0.1.39/tests/test_gateway_elicitation_preflight.py +193 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_library_search_paths.py +15 -2
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_linux_native_packaging.py +34 -12
- gopher_mcp_python-0.1.39/tests/test_oauth_auto_custom_idp.py +155 -0
- gopher_mcp_python-0.1.39/tests/test_oauth_auto_custom_idp_failures.py +285 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_resolver.py +5 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_runtime_options.py +6 -1
- gopher_mcp_python-0.1.39/tests/test_oauth_test_token_helper.py +184 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/LICENSE +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/__init__.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/errors.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/gopher_auth.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/scope_helpers.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/errors.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/__init__.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/__init__.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/auth_client.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/auto_refresh.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/config_loader.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/loader.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/oauth_client.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/session_manager.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/types.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/validation_options.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_authorization_url.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_browser.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_discovery.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_loopback.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_pkce.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_registration.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_server_targets.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_token_exchange.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_token_store.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/result.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/server_config.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/dependency_links.txt +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/requires.txt +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/top_level.txt +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/setup.cfg +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/setup.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_create_by.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_error_message.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_lifecycle.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_auth_exports.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_dump_version.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_ffi.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_gopher_auth.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_header_create_by_url_example.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_authorization_url.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_browser.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_create_with_url_integration.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_discovery.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_loopback.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_pkce.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_registration.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_server_targets.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_token_exchange.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_token_store.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_result.py +0 -0
- {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_server_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gopher-mcp-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.39
|
|
4
4
|
Summary: Python SDK for Gopher MCP - AI Agent orchestration framework with native performance
|
|
5
5
|
Author-email: Gopher Security <dev@gophersecurity.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -219,6 +219,42 @@ Result class with status and metadata.
|
|
|
219
219
|
- `result.is_error()` - Check if error
|
|
220
220
|
- `result.is_timeout()` - Check if timeout
|
|
221
221
|
|
|
222
|
+
### Provider OAuth Elicitation
|
|
223
|
+
|
|
224
|
+
Provider OAuth requested during a tool call is handled automatically by default.
|
|
225
|
+
For URL-mode elicitation, the SDK opens the authorization URL when possible,
|
|
226
|
+
prints it as a fallback, waits for the user to finish the browser flow, and then
|
|
227
|
+
lets the native agent retry the tool call.
|
|
228
|
+
|
|
229
|
+
No example-level elicitation configuration is required:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
agent = GopherAgent.create_with_url(provider, model, mcp_url)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Use `GopherAgentCreateOptions` only when you need a custom synchronous handler
|
|
236
|
+
or want to disable browser opening while still printing the URL:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from gopher_mcp_python import GopherAgentCreateOptions
|
|
240
|
+
|
|
241
|
+
agent = GopherAgent.create_with_url(
|
|
242
|
+
provider,
|
|
243
|
+
model,
|
|
244
|
+
mcp_url,
|
|
245
|
+
GopherAgentCreateOptions(
|
|
246
|
+
elicitation={
|
|
247
|
+
"open_browser": False,
|
|
248
|
+
"handler": lambda request: "accept",
|
|
249
|
+
},
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
First-step MCP server OAuth is controlled separately by the `oauth` create
|
|
255
|
+
option. Setting `oauth={"mode": "disabled"}` skips SDK OAuth discovery for the
|
|
256
|
+
MCP endpoint but still leaves provider elicitation enabled.
|
|
257
|
+
|
|
222
258
|
### Exceptions
|
|
223
259
|
|
|
224
260
|
- `AgentError` - Base exception for agent errors
|
|
@@ -234,6 +270,10 @@ Result class with status and metadata.
|
|
|
234
270
|
pytest
|
|
235
271
|
```
|
|
236
272
|
|
|
273
|
+
For deterministic OAuth auto verification with a local custom IdP and local MCP
|
|
274
|
+
server/gateway endpoints, see
|
|
275
|
+
[`docs/oauth-auto-custom-idp.md`](docs/oauth-auto-custom-idp.md).
|
|
276
|
+
|
|
237
277
|
### Code Formatting
|
|
238
278
|
|
|
239
279
|
This project uses Black for code formatting and Ruff for linting.
|
|
@@ -175,6 +175,42 @@ Result class with status and metadata.
|
|
|
175
175
|
- `result.is_error()` - Check if error
|
|
176
176
|
- `result.is_timeout()` - Check if timeout
|
|
177
177
|
|
|
178
|
+
### Provider OAuth Elicitation
|
|
179
|
+
|
|
180
|
+
Provider OAuth requested during a tool call is handled automatically by default.
|
|
181
|
+
For URL-mode elicitation, the SDK opens the authorization URL when possible,
|
|
182
|
+
prints it as a fallback, waits for the user to finish the browser flow, and then
|
|
183
|
+
lets the native agent retry the tool call.
|
|
184
|
+
|
|
185
|
+
No example-level elicitation configuration is required:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
agent = GopherAgent.create_with_url(provider, model, mcp_url)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Use `GopherAgentCreateOptions` only when you need a custom synchronous handler
|
|
192
|
+
or want to disable browser opening while still printing the URL:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from gopher_mcp_python import GopherAgentCreateOptions
|
|
196
|
+
|
|
197
|
+
agent = GopherAgent.create_with_url(
|
|
198
|
+
provider,
|
|
199
|
+
model,
|
|
200
|
+
mcp_url,
|
|
201
|
+
GopherAgentCreateOptions(
|
|
202
|
+
elicitation={
|
|
203
|
+
"open_browser": False,
|
|
204
|
+
"handler": lambda request: "accept",
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
First-step MCP server OAuth is controlled separately by the `oauth` create
|
|
211
|
+
option. Setting `oauth={"mode": "disabled"}` skips SDK OAuth discovery for the
|
|
212
|
+
MCP endpoint but still leaves provider elicitation enabled.
|
|
213
|
+
|
|
178
214
|
### Exceptions
|
|
179
215
|
|
|
180
216
|
- `AgentError` - Base exception for agent errors
|
|
@@ -190,6 +226,10 @@ Result class with status and metadata.
|
|
|
190
226
|
pytest
|
|
191
227
|
```
|
|
192
228
|
|
|
229
|
+
For deterministic OAuth auto verification with a local custom IdP and local MCP
|
|
230
|
+
server/gateway endpoints, see
|
|
231
|
+
[`docs/oauth-auto-custom-idp.md`](docs/oauth-auto-custom-idp.md).
|
|
232
|
+
|
|
193
233
|
### Code Formatting
|
|
194
234
|
|
|
195
235
|
This project uses Black for code formatting and Ruff for linting.
|
|
@@ -29,6 +29,13 @@ from gopher_mcp_python.config import (
|
|
|
29
29
|
GopherAgentConfig,
|
|
30
30
|
GopherAgentConfigBuilder,
|
|
31
31
|
)
|
|
32
|
+
from gopher_mcp_python.elicitation import (
|
|
33
|
+
GopherAgentElicitationAction,
|
|
34
|
+
GopherAgentElicitationHandler,
|
|
35
|
+
GopherAgentElicitationOptions,
|
|
36
|
+
GopherAgentElicitationRequest,
|
|
37
|
+
GopherAgentElicitationResponse,
|
|
38
|
+
)
|
|
32
39
|
from gopher_mcp_python.runtime_options import (
|
|
33
40
|
GopherAgentCreateOptions,
|
|
34
41
|
GopherAgentOAuthOptions,
|
|
@@ -46,7 +53,7 @@ from gopher_mcp_python.errors import (
|
|
|
46
53
|
from gopher_mcp_python.server_config import ServerConfig, ServerConfigRoute
|
|
47
54
|
from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle
|
|
48
55
|
|
|
49
|
-
__version__ = "0.1.
|
|
56
|
+
__version__ = "0.1.39"
|
|
50
57
|
|
|
51
58
|
_AUTH_EXPORTS = {
|
|
52
59
|
"GopherAuth",
|
|
@@ -128,6 +135,11 @@ __all__ = [
|
|
|
128
135
|
"GopherAgentConfig",
|
|
129
136
|
"GopherAgentConfigBuilder",
|
|
130
137
|
"GopherAgentCreateOptions",
|
|
138
|
+
"GopherAgentElicitationAction",
|
|
139
|
+
"GopherAgentElicitationHandler",
|
|
140
|
+
"GopherAgentElicitationOptions",
|
|
141
|
+
"GopherAgentElicitationRequest",
|
|
142
|
+
"GopherAgentElicitationResponse",
|
|
131
143
|
"GopherAgentOAuthOptions",
|
|
132
144
|
"GopherAgentRuntimeOptions",
|
|
133
145
|
"GopherAgentTokenRecord",
|
|
@@ -31,9 +31,13 @@ import asyncio
|
|
|
31
31
|
import weakref
|
|
32
32
|
from typing import Callable, Optional
|
|
33
33
|
|
|
34
|
+
from gopher_mcp_python.gateway_elicitation_preflight import (
|
|
35
|
+
preflight_gateway_elicitation_with_status,
|
|
36
|
+
)
|
|
34
37
|
import gopher_mcp_python.oauth_resolver as oauth_resolver
|
|
35
38
|
from gopher_mcp_python.config import GopherAgentConfig
|
|
36
39
|
from gopher_mcp_python.runtime_options import (
|
|
40
|
+
GopherAgentCreateOptions,
|
|
37
41
|
GopherAgentOAuthOptions,
|
|
38
42
|
GopherAgentRuntimeOptions,
|
|
39
43
|
RuntimeOptionsInput,
|
|
@@ -47,6 +51,8 @@ from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle
|
|
|
47
51
|
|
|
48
52
|
_initialized = False
|
|
49
53
|
_cleanup_handler_registered = False
|
|
54
|
+
_SKIP_DISCOVERY_ELICITATION_HEADER = "X-Gopher-Internal-Skip-Discovery-Elicitation"
|
|
55
|
+
_PREFLIGHT_MCP_SESSION_HEADER = "X-Gopher-Internal-Preflight-Mcp-Session-Id"
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
class GopherAgent:
|
|
@@ -159,7 +165,7 @@ class GopherAgent:
|
|
|
159
165
|
Returns:
|
|
160
166
|
GopherAgent instance
|
|
161
167
|
"""
|
|
162
|
-
create_options =
|
|
168
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
163
169
|
runtime_options = normalize_runtime_options(create_options)
|
|
164
170
|
oauth = create_options.oauth if create_options is not None else None
|
|
165
171
|
if _should_skip_oauth(runtime_options, oauth):
|
|
@@ -197,7 +203,7 @@ class GopherAgent:
|
|
|
197
203
|
Returns:
|
|
198
204
|
GopherAgent instance
|
|
199
205
|
"""
|
|
200
|
-
create_options =
|
|
206
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
201
207
|
runtime_options = normalize_runtime_options(create_options)
|
|
202
208
|
oauth = create_options.oauth if create_options is not None else None
|
|
203
209
|
if _should_skip_oauth(runtime_options, oauth):
|
|
@@ -246,7 +252,7 @@ class GopherAgent:
|
|
|
246
252
|
Returns:
|
|
247
253
|
GopherAgent instance
|
|
248
254
|
"""
|
|
249
|
-
create_options =
|
|
255
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
250
256
|
normalized_runtime_options = normalize_runtime_options(create_options)
|
|
251
257
|
oauth = create_options.oauth if create_options is not None else None
|
|
252
258
|
if _should_skip_oauth(normalized_runtime_options, oauth):
|
|
@@ -289,7 +295,7 @@ class GopherAgent:
|
|
|
289
295
|
Returns:
|
|
290
296
|
GopherAgent instance
|
|
291
297
|
"""
|
|
292
|
-
create_options =
|
|
298
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
293
299
|
normalized_runtime_options = normalize_runtime_options(create_options)
|
|
294
300
|
oauth = create_options.oauth if create_options is not None else None
|
|
295
301
|
if _should_skip_oauth(normalized_runtime_options, oauth):
|
|
@@ -332,7 +338,7 @@ class GopherAgent:
|
|
|
332
338
|
Returns:
|
|
333
339
|
GopherAgent instance
|
|
334
340
|
"""
|
|
335
|
-
create_options =
|
|
341
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
336
342
|
normalized_runtime_options = normalize_runtime_options(create_options)
|
|
337
343
|
oauth = create_options.oauth if create_options is not None else None
|
|
338
344
|
if _should_skip_oauth(normalized_runtime_options, oauth):
|
|
@@ -375,7 +381,7 @@ class GopherAgent:
|
|
|
375
381
|
Returns:
|
|
376
382
|
GopherAgent instance
|
|
377
383
|
"""
|
|
378
|
-
create_options =
|
|
384
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
379
385
|
normalized_runtime_options = normalize_runtime_options(create_options)
|
|
380
386
|
oauth = create_options.oauth if create_options is not None else None
|
|
381
387
|
if _should_skip_oauth(normalized_runtime_options, oauth):
|
|
@@ -417,7 +423,7 @@ class GopherAgent:
|
|
|
417
423
|
Returns:
|
|
418
424
|
GopherAgent instance
|
|
419
425
|
"""
|
|
420
|
-
create_options =
|
|
426
|
+
create_options = _normalize_create_options_for_agent(runtime_options)
|
|
421
427
|
normalized_runtime_options = normalize_runtime_options(create_options)
|
|
422
428
|
oauth = create_options.oauth if create_options is not None else None
|
|
423
429
|
if url != "" and not _should_skip_oauth(normalized_runtime_options, oauth):
|
|
@@ -428,6 +434,16 @@ class GopherAgent:
|
|
|
428
434
|
oauth=oauth,
|
|
429
435
|
)
|
|
430
436
|
)
|
|
437
|
+
preflight = preflight_gateway_elicitation_with_status(
|
|
438
|
+
url,
|
|
439
|
+
normalized_runtime_options,
|
|
440
|
+
create_options,
|
|
441
|
+
)
|
|
442
|
+
normalized_runtime_options = _mark_discovery_elicitation_preflighted(
|
|
443
|
+
preflight.runtime_options,
|
|
444
|
+
preflight.handled,
|
|
445
|
+
preflight.session,
|
|
446
|
+
)
|
|
431
447
|
return GopherAgent._create_from_ffi(
|
|
432
448
|
lambda lib: lib.agent_create_by_url(
|
|
433
449
|
provider, model, url, normalized_runtime_options
|
|
@@ -605,6 +621,13 @@ def _run_oauth_coroutine(create_coroutine):
|
|
|
605
621
|
)
|
|
606
622
|
|
|
607
623
|
|
|
624
|
+
def _normalize_create_options_for_agent(
|
|
625
|
+
runtime_options: RuntimeOptionsInput,
|
|
626
|
+
) -> GopherAgentCreateOptions:
|
|
627
|
+
create_options = normalize_create_options(runtime_options)
|
|
628
|
+
return create_options if create_options is not None else GopherAgentCreateOptions()
|
|
629
|
+
|
|
630
|
+
|
|
608
631
|
def _should_skip_oauth(
|
|
609
632
|
runtime_options: Optional[GopherAgentRuntimeOptions],
|
|
610
633
|
oauth: Optional[GopherAgentOAuthOptions],
|
|
@@ -618,6 +641,24 @@ def _should_skip_oauth(
|
|
|
618
641
|
return any(name.lower() == "authorization" for name in runtime_options.headers)
|
|
619
642
|
|
|
620
643
|
|
|
644
|
+
def _mark_discovery_elicitation_preflighted(
|
|
645
|
+
runtime_options: Optional[GopherAgentRuntimeOptions],
|
|
646
|
+
handled: bool,
|
|
647
|
+
session: Optional[str],
|
|
648
|
+
) -> Optional[GopherAgentRuntimeOptions]:
|
|
649
|
+
if not handled:
|
|
650
|
+
return runtime_options
|
|
651
|
+
headers = dict(runtime_options.headers) if runtime_options is not None else {}
|
|
652
|
+
headers[_SKIP_DISCOVERY_ELICITATION_HEADER] = "1"
|
|
653
|
+
if session:
|
|
654
|
+
headers[_PREFLIGHT_MCP_SESSION_HEADER] = session
|
|
655
|
+
return GopherAgentRuntimeOptions(
|
|
656
|
+
access_token=runtime_options.access_token if runtime_options else None,
|
|
657
|
+
headers=headers,
|
|
658
|
+
elicitation=runtime_options.elicitation if runtime_options else None,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
621
662
|
def _build_create_error_message() -> str:
|
|
622
663
|
"""
|
|
623
664
|
Build the AgentError message for a null native create*() result.
|
|
@@ -206,13 +206,19 @@ class GopherAgentConfigBuilder:
|
|
|
206
206
|
current_oauth = (
|
|
207
207
|
self._runtime_options.oauth if self._runtime_options is not None else None
|
|
208
208
|
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
"oauth": current_oauth,
|
|
214
|
-
}
|
|
209
|
+
current_elicitation = (
|
|
210
|
+
self._runtime_options.elicitation
|
|
211
|
+
if self._runtime_options is not None
|
|
212
|
+
else None
|
|
215
213
|
)
|
|
214
|
+
next_options = {
|
|
215
|
+
"access_token": access_token,
|
|
216
|
+
"headers": current_headers,
|
|
217
|
+
"oauth": current_oauth,
|
|
218
|
+
}
|
|
219
|
+
if current_elicitation is not None:
|
|
220
|
+
next_options["elicitation"] = current_elicitation
|
|
221
|
+
self._runtime_options = normalize_create_options(next_options)
|
|
216
222
|
return self
|
|
217
223
|
|
|
218
224
|
def headers(self, headers: Mapping[str, str]) -> "GopherAgentConfigBuilder":
|
|
@@ -233,13 +239,19 @@ class GopherAgentConfigBuilder:
|
|
|
233
239
|
current_oauth = (
|
|
234
240
|
self._runtime_options.oauth if self._runtime_options is not None else None
|
|
235
241
|
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"oauth": current_oauth,
|
|
241
|
-
}
|
|
242
|
+
current_elicitation = (
|
|
243
|
+
self._runtime_options.elicitation
|
|
244
|
+
if self._runtime_options is not None
|
|
245
|
+
else None
|
|
242
246
|
)
|
|
247
|
+
next_options = {
|
|
248
|
+
"access_token": current_token,
|
|
249
|
+
"headers": headers,
|
|
250
|
+
"oauth": current_oauth,
|
|
251
|
+
}
|
|
252
|
+
if current_elicitation is not None:
|
|
253
|
+
next_options["elicitation"] = current_elicitation
|
|
254
|
+
self._runtime_options = normalize_create_options(next_options)
|
|
243
255
|
return self
|
|
244
256
|
|
|
245
257
|
def build(self) -> GopherAgentConfig:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""MCP server-to-client elicitation support."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
GopherAgentElicitationAction = str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GopherAgentElicitationRequest:
|
|
10
|
+
"""Server-provided MCP elicitation/create request."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
mode: str,
|
|
15
|
+
elicitation_id: Optional[str] = None,
|
|
16
|
+
message: Optional[str] = None,
|
|
17
|
+
url: Optional[str] = None,
|
|
18
|
+
request_id_json: Optional[str] = None,
|
|
19
|
+
raw_json: Optional[str] = None,
|
|
20
|
+
raw_params_json: Optional[str] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
if not isinstance(mode, str):
|
|
23
|
+
raise ValueError("elicitation mode must be a string")
|
|
24
|
+
self.mode = mode
|
|
25
|
+
self.elicitation_id = _optional_string(elicitation_id, "elicitation_id")
|
|
26
|
+
self.message = _optional_string(message, "message")
|
|
27
|
+
self.url = _optional_string(url, "url")
|
|
28
|
+
self.request_id_json = _optional_string(request_id_json, "request_id_json")
|
|
29
|
+
self.raw_json = _optional_string(raw_json, "raw_json")
|
|
30
|
+
self.raw_params_json = _optional_string(raw_params_json, "raw_params_json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GopherAgentElicitationResponse:
|
|
34
|
+
"""Application response to an MCP elicitation request."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, action: GopherAgentElicitationAction) -> None:
|
|
37
|
+
self.action = normalize_elicitation_action(action)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
GopherAgentElicitationHandler = Callable[
|
|
41
|
+
[GopherAgentElicitationRequest],
|
|
42
|
+
Union[GopherAgentElicitationResponse, GopherAgentElicitationAction],
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GopherAgentElicitationOptions:
|
|
47
|
+
"""Options for MCP server-to-client elicitation handling."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
handler: Optional[GopherAgentElicitationHandler] = None,
|
|
52
|
+
timeout_ms: Optional[int] = None,
|
|
53
|
+
open_browser: Optional[bool] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if handler is not None and not callable(handler):
|
|
56
|
+
raise ValueError("elicitation handler must be callable")
|
|
57
|
+
if timeout_ms is not None:
|
|
58
|
+
if not isinstance(timeout_ms, (int, float)) or not _is_finite(timeout_ms):
|
|
59
|
+
raise ValueError("elicitation timeout_ms must be a finite number")
|
|
60
|
+
timeout_ms = max(0, int(timeout_ms))
|
|
61
|
+
if open_browser is not None and not isinstance(open_browser, bool):
|
|
62
|
+
raise ValueError("elicitation open_browser must be a boolean")
|
|
63
|
+
self.handler = handler
|
|
64
|
+
self.timeout_ms = timeout_ms
|
|
65
|
+
self.open_browser = open_browser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_elicitation_options(
|
|
69
|
+
options: Optional[
|
|
70
|
+
Union[GopherAgentElicitationOptions, Mapping[str, Any]]
|
|
71
|
+
] = None,
|
|
72
|
+
) -> GopherAgentElicitationOptions:
|
|
73
|
+
"""Normalize elicitation options, defaulting omitted options to enabled."""
|
|
74
|
+
if options is None:
|
|
75
|
+
return GopherAgentElicitationOptions()
|
|
76
|
+
|
|
77
|
+
if isinstance(options, GopherAgentElicitationOptions):
|
|
78
|
+
return options
|
|
79
|
+
|
|
80
|
+
if isinstance(options, Mapping):
|
|
81
|
+
timeout_ms = (
|
|
82
|
+
options.get("timeout_ms")
|
|
83
|
+
if "timeout_ms" in options
|
|
84
|
+
else options.get("timeoutMs")
|
|
85
|
+
)
|
|
86
|
+
open_browser = (
|
|
87
|
+
options.get("open_browser")
|
|
88
|
+
if "open_browser" in options
|
|
89
|
+
else options.get("openBrowser")
|
|
90
|
+
)
|
|
91
|
+
return GopherAgentElicitationOptions(
|
|
92
|
+
handler=options.get("handler"),
|
|
93
|
+
timeout_ms=timeout_ms,
|
|
94
|
+
open_browser=open_browser,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"elicitation options must be a GopherAgentElicitationOptions instance "
|
|
99
|
+
"or mapping"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_elicitation_action(
|
|
104
|
+
response: Union[GopherAgentElicitationResponse, GopherAgentElicitationAction]
|
|
105
|
+
) -> GopherAgentElicitationAction:
|
|
106
|
+
"""Normalize and validate an MCP elicitation action."""
|
|
107
|
+
action = response.action if isinstance(response, GopherAgentElicitationResponse) else response
|
|
108
|
+
if action in ("accept", "decline", "cancel"):
|
|
109
|
+
return action
|
|
110
|
+
raise ValueError(f"unsupported MCP elicitation action: {action}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _optional_string(value: Optional[str], name: str) -> Optional[str]:
|
|
114
|
+
if value is not None and not isinstance(value, str):
|
|
115
|
+
raise ValueError(f"elicitation {name} must be a string")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_finite(value: Union[int, float]) -> bool:
|
|
120
|
+
return value == value and value not in (float("inf"), float("-inf"))
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Runtime helpers for MCP elicitation callbacks."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import selectors
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
8
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from gopher_mcp_python.elicitation import (
|
|
11
|
+
GopherAgentElicitationAction,
|
|
12
|
+
GopherAgentElicitationHandler,
|
|
13
|
+
GopherAgentElicitationOptions,
|
|
14
|
+
GopherAgentElicitationRequest,
|
|
15
|
+
GopherAgentElicitationResponse,
|
|
16
|
+
normalize_elicitation_action,
|
|
17
|
+
)
|
|
18
|
+
from gopher_mcp_python.oauth_browser import open_authorization_url
|
|
19
|
+
|
|
20
|
+
ELICITATION_ACTION_ACCEPT = 1
|
|
21
|
+
ELICITATION_ACTION_DECLINE = 2
|
|
22
|
+
ELICITATION_ACTION_CANCEL = 3
|
|
23
|
+
|
|
24
|
+
_read_input: Callable[[Optional[int]], Optional[str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_elicitation_request(
|
|
28
|
+
request: Mapping[str, Optional[str]]
|
|
29
|
+
) -> GopherAgentElicitationRequest:
|
|
30
|
+
"""Convert native request fields into the public Python request object."""
|
|
31
|
+
return GopherAgentElicitationRequest(
|
|
32
|
+
mode=request.get("mode") or "",
|
|
33
|
+
elicitation_id=request.get("elicitation_id"),
|
|
34
|
+
message=request.get("message"),
|
|
35
|
+
url=request.get("url"),
|
|
36
|
+
request_id_json=request.get("request_id_json"),
|
|
37
|
+
raw_json=request.get("raw_json"),
|
|
38
|
+
raw_params_json=request.get("raw_params_json"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_elicitation_action_sync(
|
|
43
|
+
options: GopherAgentElicitationOptions,
|
|
44
|
+
request: GopherAgentElicitationRequest,
|
|
45
|
+
) -> GopherAgentElicitationAction:
|
|
46
|
+
"""Resolve an elicitation request using a synchronous handler."""
|
|
47
|
+
handler = options.handler or default_url_elicitation_handler(options)
|
|
48
|
+
_log_elicitation_debug("request", _summarize_elicitation_request(request))
|
|
49
|
+
response = handler(request)
|
|
50
|
+
if inspect.isawaitable(response):
|
|
51
|
+
if inspect.iscoroutine(response):
|
|
52
|
+
response.close()
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"Async MCP elicitation handlers are not supported by the native "
|
|
55
|
+
"FFI bridge yet"
|
|
56
|
+
)
|
|
57
|
+
action = normalize_elicitation_action(response)
|
|
58
|
+
_log_elicitation_debug(
|
|
59
|
+
"response",
|
|
60
|
+
{
|
|
61
|
+
"elicitation_id": request.elicitation_id,
|
|
62
|
+
"mode": request.mode,
|
|
63
|
+
"action": action,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
return action
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_url_elicitation_handler(
|
|
70
|
+
options: Optional[GopherAgentElicitationOptions] = None,
|
|
71
|
+
) -> GopherAgentElicitationHandler:
|
|
72
|
+
"""Return the SDK's default URL-mode provider OAuth handler."""
|
|
73
|
+
resolved_options = options or GopherAgentElicitationOptions()
|
|
74
|
+
|
|
75
|
+
def handle(
|
|
76
|
+
request: GopherAgentElicitationRequest,
|
|
77
|
+
) -> GopherAgentElicitationResponse:
|
|
78
|
+
if request.mode != "url" or not request.url:
|
|
79
|
+
return GopherAgentElicitationResponse("decline")
|
|
80
|
+
result = open_authorization_url(
|
|
81
|
+
request.url,
|
|
82
|
+
open_browser=resolved_options.open_browser,
|
|
83
|
+
)
|
|
84
|
+
if not result.get("opened"):
|
|
85
|
+
print(
|
|
86
|
+
"Open this OAuth authorization URL to continue:\n"
|
|
87
|
+
f"{request.url}",
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
return GopherAgentElicitationResponse(
|
|
91
|
+
wait_for_oauth_completion_sync(resolved_options.timeout_ms)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return handle
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def wait_for_oauth_completion_sync(
|
|
98
|
+
timeout_ms: Optional[int] = None,
|
|
99
|
+
) -> GopherAgentElicitationAction:
|
|
100
|
+
"""Wait synchronously for the user to finish browser OAuth."""
|
|
101
|
+
print(
|
|
102
|
+
'Complete the OAuth flow in the browser, then press Enter to continue. '
|
|
103
|
+
'Type "cancel" and press Enter to cancel.',
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
value = _read_input(timeout_ms)
|
|
107
|
+
if value is None:
|
|
108
|
+
print(
|
|
109
|
+
"Cannot access an interactive terminal; canceling provider "
|
|
110
|
+
"authorization.",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return "cancel"
|
|
114
|
+
return "cancel" if value.strip().lower() == "cancel" else "accept"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def native_action_from_elicitation_action(
|
|
118
|
+
action: GopherAgentElicitationAction,
|
|
119
|
+
) -> int:
|
|
120
|
+
"""Map public action strings to native integer constants."""
|
|
121
|
+
if action == "accept":
|
|
122
|
+
return ELICITATION_ACTION_ACCEPT
|
|
123
|
+
if action == "decline":
|
|
124
|
+
return ELICITATION_ACTION_DECLINE
|
|
125
|
+
if action == "cancel":
|
|
126
|
+
return ELICITATION_ACTION_CANCEL
|
|
127
|
+
raise ValueError(f"unsupported MCP elicitation action: {action}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_elicitation_input_for_test(
|
|
131
|
+
read_input: Optional[Callable[[Optional[int]], Optional[str]]],
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Override terminal input for tests."""
|
|
134
|
+
global _read_input
|
|
135
|
+
_read_input = read_input or _read_terminal_input
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def redact_elicitation_url(url: str) -> str:
|
|
139
|
+
"""Redact sensitive OAuth query fields for diagnostic logging."""
|
|
140
|
+
try:
|
|
141
|
+
parsed = urlsplit(url)
|
|
142
|
+
query = urlencode(
|
|
143
|
+
[
|
|
144
|
+
(
|
|
145
|
+
name,
|
|
146
|
+
"<redacted>" if _is_sensitive_query_name(name) else "<present>",
|
|
147
|
+
)
|
|
148
|
+
for name, _value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
fragment = "<redacted>" if parsed.fragment else ""
|
|
152
|
+
return urlunsplit(
|
|
153
|
+
(parsed.scheme, parsed.netloc, parsed.path, query, fragment)
|
|
154
|
+
)
|
|
155
|
+
except Exception:
|
|
156
|
+
return "<invalid-url>"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _read_terminal_input(timeout_ms: Optional[int]) -> Optional[str]:
|
|
160
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
161
|
+
return None
|
|
162
|
+
if timeout_ms is not None and timeout_ms > 0:
|
|
163
|
+
return _read_terminal_input_with_timeout(timeout_ms)
|
|
164
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
165
|
+
return None
|
|
166
|
+
return sys.stdin.readline()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_terminal_input_with_timeout(timeout_ms: int) -> Optional[str]:
|
|
170
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
171
|
+
return None
|
|
172
|
+
selector = None
|
|
173
|
+
try:
|
|
174
|
+
selector = selectors.DefaultSelector()
|
|
175
|
+
selector.register(sys.stdin, selectors.EVENT_READ)
|
|
176
|
+
events = selector.select(timeout_ms / 1000.0)
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
finally:
|
|
180
|
+
if selector is not None:
|
|
181
|
+
try:
|
|
182
|
+
selector.close()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
if not events:
|
|
186
|
+
print(
|
|
187
|
+
"Timed out waiting for OAuth completion; canceling provider "
|
|
188
|
+
"authorization.",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
return None
|
|
192
|
+
return sys.stdin.readline()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _summarize_elicitation_request(
|
|
196
|
+
request: GopherAgentElicitationRequest,
|
|
197
|
+
) -> Dict[str, Optional[str]]:
|
|
198
|
+
host = None
|
|
199
|
+
if request.url:
|
|
200
|
+
try:
|
|
201
|
+
host = urlsplit(request.url).netloc
|
|
202
|
+
except Exception:
|
|
203
|
+
host = None
|
|
204
|
+
return {
|
|
205
|
+
"elicitation_id": request.elicitation_id,
|
|
206
|
+
"mode": request.mode,
|
|
207
|
+
"host": host,
|
|
208
|
+
"url": redact_elicitation_url(request.url) if request.url else None,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _log_elicitation_debug(label: str, values: Any) -> None:
|
|
213
|
+
if os.environ.get("GOPHER_MCP_OAUTH_DEBUG") != "1" and os.environ.get("DEBUG") != "1":
|
|
214
|
+
return
|
|
215
|
+
print(f"[gopher-mcp-python elicitation] {label}: {values}", file=sys.stderr)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_sensitive_query_name(name: str) -> bool:
|
|
219
|
+
normalized = name.lower()
|
|
220
|
+
return (
|
|
221
|
+
normalized == "state"
|
|
222
|
+
or normalized == "code"
|
|
223
|
+
or normalized == "client_secret"
|
|
224
|
+
or "token" in normalized
|
|
225
|
+
or "secret" in normalized
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
_read_input = _read_terminal_input
|