gopher-mcp-python 0.1.34__tar.gz → 0.1.38__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.38}/PKG-INFO +41 -1
  2. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/README.md +40 -0
  3. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/__init__.py +13 -1
  4. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/agent.py +23 -7
  5. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/config.py +24 -12
  6. gopher_mcp_python-0.1.38/gopher_mcp_python/elicitation.py +120 -0
  7. gopher_mcp_python-0.1.38/gopher_mcp_python/elicitation_runtime.py +229 -0
  8. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/library.py +289 -24
  9. gopher_mcp_python-0.1.38/gopher_mcp_python/gateway_elicitation_preflight.py +316 -0
  10. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_resolver.py +1 -0
  11. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_runtime_options.py +1 -0
  12. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/runtime_options.py +55 -8
  13. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python.egg-info/PKG-INFO +41 -1
  14. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python.egg-info/SOURCES.txt +10 -0
  15. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/pyproject.toml +1 -1
  16. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_agent_create_with_oauth.py +67 -9
  17. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_agent_runtime_options.py +10 -1
  18. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_config.py +21 -0
  19. gopher_mcp_python-0.1.38/tests/test_custom_oauth_test_idp.py +186 -0
  20. gopher_mcp_python-0.1.38/tests/test_custom_protected_mcp_endpoints.py +173 -0
  21. gopher_mcp_python-0.1.38/tests/test_elicitation_runtime.py +157 -0
  22. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_ffi_runtime_options.py +106 -0
  23. gopher_mcp_python-0.1.38/tests/test_gateway_elicitation_preflight.py +117 -0
  24. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_library_search_paths.py +15 -2
  25. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_linux_native_packaging.py +34 -12
  26. gopher_mcp_python-0.1.38/tests/test_oauth_auto_custom_idp.py +155 -0
  27. gopher_mcp_python-0.1.38/tests/test_oauth_auto_custom_idp_failures.py +285 -0
  28. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_resolver.py +5 -0
  29. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_runtime_options.py +6 -1
  30. gopher_mcp_python-0.1.38/tests/test_oauth_test_token_helper.py +184 -0
  31. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/LICENSE +0 -0
  32. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/auth/__init__.py +0 -0
  33. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/auth/errors.py +0 -0
  34. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/auth/gopher_auth.py +0 -0
  35. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/auth/scope_helpers.py +0 -0
  36. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/errors.py +0 -0
  37. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/__init__.py +0 -0
  38. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/__init__.py +0 -0
  39. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/auth_client.py +0 -0
  40. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/auto_refresh.py +0 -0
  41. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/config_loader.py +0 -0
  42. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/loader.py +0 -0
  43. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/oauth_client.py +0 -0
  44. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/session_manager.py +0 -0
  45. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/types.py +0 -0
  46. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/ffi/auth/validation_options.py +0 -0
  47. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_authorization_url.py +0 -0
  48. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_browser.py +0 -0
  49. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_discovery.py +0 -0
  50. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_loopback.py +0 -0
  51. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_pkce.py +0 -0
  52. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_registration.py +0 -0
  53. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_server_targets.py +0 -0
  54. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_token_exchange.py +0 -0
  55. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/oauth_token_store.py +0 -0
  56. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/result.py +0 -0
  57. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python/server_config.py +0 -0
  58. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python.egg-info/dependency_links.txt +0 -0
  59. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python.egg-info/requires.txt +0 -0
  60. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/gopher_mcp_python.egg-info/top_level.txt +0 -0
  61. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/setup.cfg +0 -0
  62. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/setup.py +0 -0
  63. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_agent_create_by.py +0 -0
  64. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_agent_error_message.py +0 -0
  65. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_agent_lifecycle.py +0 -0
  66. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_auth_exports.py +0 -0
  67. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_dump_version.py +0 -0
  68. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_ffi.py +0 -0
  69. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_gopher_auth.py +0 -0
  70. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_header_create_by_url_example.py +0 -0
  71. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_authorization_url.py +0 -0
  72. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_browser.py +0 -0
  73. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_create_with_url_integration.py +0 -0
  74. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_discovery.py +0 -0
  75. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_loopback.py +0 -0
  76. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_pkce.py +0 -0
  77. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_registration.py +0 -0
  78. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_server_targets.py +0 -0
  79. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_token_exchange.py +0 -0
  80. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_oauth_token_store.py +0 -0
  81. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/tests/test_result.py +0 -0
  82. {gopher_mcp_python-0.1.34 → gopher_mcp_python-0.1.38}/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.38
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.38"
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,
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,
@@ -159,7 +163,7 @@ class GopherAgent:
159
163
  Returns:
160
164
  GopherAgent instance
161
165
  """
162
- create_options = normalize_create_options(runtime_options)
166
+ create_options = _normalize_create_options_for_agent(runtime_options)
163
167
  runtime_options = normalize_runtime_options(create_options)
164
168
  oauth = create_options.oauth if create_options is not None else None
165
169
  if _should_skip_oauth(runtime_options, oauth):
@@ -197,7 +201,7 @@ class GopherAgent:
197
201
  Returns:
198
202
  GopherAgent instance
199
203
  """
200
- create_options = normalize_create_options(runtime_options)
204
+ create_options = _normalize_create_options_for_agent(runtime_options)
201
205
  runtime_options = normalize_runtime_options(create_options)
202
206
  oauth = create_options.oauth if create_options is not None else None
203
207
  if _should_skip_oauth(runtime_options, oauth):
@@ -246,7 +250,7 @@ class GopherAgent:
246
250
  Returns:
247
251
  GopherAgent instance
248
252
  """
249
- create_options = normalize_create_options(runtime_options)
253
+ create_options = _normalize_create_options_for_agent(runtime_options)
250
254
  normalized_runtime_options = normalize_runtime_options(create_options)
251
255
  oauth = create_options.oauth if create_options is not None else None
252
256
  if _should_skip_oauth(normalized_runtime_options, oauth):
@@ -289,7 +293,7 @@ class GopherAgent:
289
293
  Returns:
290
294
  GopherAgent instance
291
295
  """
292
- create_options = normalize_create_options(runtime_options)
296
+ create_options = _normalize_create_options_for_agent(runtime_options)
293
297
  normalized_runtime_options = normalize_runtime_options(create_options)
294
298
  oauth = create_options.oauth if create_options is not None else None
295
299
  if _should_skip_oauth(normalized_runtime_options, oauth):
@@ -332,7 +336,7 @@ class GopherAgent:
332
336
  Returns:
333
337
  GopherAgent instance
334
338
  """
335
- create_options = normalize_create_options(runtime_options)
339
+ create_options = _normalize_create_options_for_agent(runtime_options)
336
340
  normalized_runtime_options = normalize_runtime_options(create_options)
337
341
  oauth = create_options.oauth if create_options is not None else None
338
342
  if _should_skip_oauth(normalized_runtime_options, oauth):
@@ -375,7 +379,7 @@ class GopherAgent:
375
379
  Returns:
376
380
  GopherAgent instance
377
381
  """
378
- create_options = normalize_create_options(runtime_options)
382
+ create_options = _normalize_create_options_for_agent(runtime_options)
379
383
  normalized_runtime_options = normalize_runtime_options(create_options)
380
384
  oauth = create_options.oauth if create_options is not None else None
381
385
  if _should_skip_oauth(normalized_runtime_options, oauth):
@@ -417,7 +421,7 @@ class GopherAgent:
417
421
  Returns:
418
422
  GopherAgent instance
419
423
  """
420
- create_options = normalize_create_options(runtime_options)
424
+ create_options = _normalize_create_options_for_agent(runtime_options)
421
425
  normalized_runtime_options = normalize_runtime_options(create_options)
422
426
  oauth = create_options.oauth if create_options is not None else None
423
427
  if url != "" and not _should_skip_oauth(normalized_runtime_options, oauth):
@@ -428,6 +432,11 @@ class GopherAgent:
428
432
  oauth=oauth,
429
433
  )
430
434
  )
435
+ normalized_runtime_options = preflight_gateway_elicitation(
436
+ url,
437
+ normalized_runtime_options,
438
+ create_options,
439
+ )
431
440
  return GopherAgent._create_from_ffi(
432
441
  lambda lib: lib.agent_create_by_url(
433
442
  provider, model, url, normalized_runtime_options
@@ -605,6 +614,13 @@ def _run_oauth_coroutine(create_coroutine):
605
614
  )
606
615
 
607
616
 
617
+ def _normalize_create_options_for_agent(
618
+ runtime_options: RuntimeOptionsInput,
619
+ ) -> GopherAgentCreateOptions:
620
+ create_options = normalize_create_options(runtime_options)
621
+ return create_options if create_options is not None else GopherAgentCreateOptions()
622
+
623
+
608
624
  def _should_skip_oauth(
609
625
  runtime_options: Optional[GopherAgentRuntimeOptions],
610
626
  oauth: Optional[GopherAgentOAuthOptions],
@@ -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