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.
Files changed (82) hide show
  1. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/PKG-INFO +41 -1
  2. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/README.md +40 -0
  3. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/__init__.py +13 -1
  4. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/agent.py +48 -7
  5. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/config.py +24 -12
  6. gopher_mcp_python-0.1.39/gopher_mcp_python/elicitation.py +120 -0
  7. gopher_mcp_python-0.1.39/gopher_mcp_python/elicitation_runtime.py +229 -0
  8. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/library.py +289 -24
  9. gopher_mcp_python-0.1.39/gopher_mcp_python/gateway_elicitation_preflight.py +326 -0
  10. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_resolver.py +1 -0
  11. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_runtime_options.py +1 -0
  12. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/runtime_options.py +55 -8
  13. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/PKG-INFO +41 -1
  14. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/SOURCES.txt +10 -0
  15. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/pyproject.toml +1 -1
  16. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_create_with_oauth.py +70 -9
  17. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_runtime_options.py +10 -1
  18. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_config.py +21 -0
  19. gopher_mcp_python-0.1.39/tests/test_custom_oauth_test_idp.py +186 -0
  20. gopher_mcp_python-0.1.39/tests/test_custom_protected_mcp_endpoints.py +173 -0
  21. gopher_mcp_python-0.1.39/tests/test_elicitation_runtime.py +157 -0
  22. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_ffi_runtime_options.py +106 -0
  23. gopher_mcp_python-0.1.39/tests/test_gateway_elicitation_preflight.py +193 -0
  24. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_library_search_paths.py +15 -2
  25. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_linux_native_packaging.py +34 -12
  26. gopher_mcp_python-0.1.39/tests/test_oauth_auto_custom_idp.py +155 -0
  27. gopher_mcp_python-0.1.39/tests/test_oauth_auto_custom_idp_failures.py +285 -0
  28. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_resolver.py +5 -0
  29. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_runtime_options.py +6 -1
  30. gopher_mcp_python-0.1.39/tests/test_oauth_test_token_helper.py +184 -0
  31. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/LICENSE +0 -0
  32. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/__init__.py +0 -0
  33. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/errors.py +0 -0
  34. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/gopher_auth.py +0 -0
  35. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/auth/scope_helpers.py +0 -0
  36. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/errors.py +0 -0
  37. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/__init__.py +0 -0
  38. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/__init__.py +0 -0
  39. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/auth_client.py +0 -0
  40. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/auto_refresh.py +0 -0
  41. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/config_loader.py +0 -0
  42. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/loader.py +0 -0
  43. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/oauth_client.py +0 -0
  44. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/session_manager.py +0 -0
  45. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/types.py +0 -0
  46. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/ffi/auth/validation_options.py +0 -0
  47. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_authorization_url.py +0 -0
  48. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_browser.py +0 -0
  49. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_discovery.py +0 -0
  50. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_loopback.py +0 -0
  51. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_pkce.py +0 -0
  52. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_registration.py +0 -0
  53. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_server_targets.py +0 -0
  54. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_token_exchange.py +0 -0
  55. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/oauth_token_store.py +0 -0
  56. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/result.py +0 -0
  57. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python/server_config.py +0 -0
  58. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/dependency_links.txt +0 -0
  59. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/requires.txt +0 -0
  60. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/gopher_mcp_python.egg-info/top_level.txt +0 -0
  61. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/setup.cfg +0 -0
  62. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/setup.py +0 -0
  63. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_create_by.py +0 -0
  64. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_error_message.py +0 -0
  65. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_agent_lifecycle.py +0 -0
  66. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_auth_exports.py +0 -0
  67. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_dump_version.py +0 -0
  68. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_ffi.py +0 -0
  69. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_gopher_auth.py +0 -0
  70. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_header_create_by_url_example.py +0 -0
  71. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_authorization_url.py +0 -0
  72. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_browser.py +0 -0
  73. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_create_with_url_integration.py +0 -0
  74. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_discovery.py +0 -0
  75. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_loopback.py +0 -0
  76. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_pkce.py +0 -0
  77. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_registration.py +0 -0
  78. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_server_targets.py +0 -0
  79. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_token_exchange.py +0 -0
  80. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_oauth_token_store.py +0 -0
  81. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.39}/tests/test_result.py +0 -0
  82. {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.34
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.34"
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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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 = normalize_create_options(runtime_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
- self._runtime_options = normalize_create_options(
210
- {
211
- "access_token": access_token,
212
- "headers": current_headers,
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
- self._runtime_options = normalize_create_options(
237
- {
238
- "access_token": current_token,
239
- "headers": headers,
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