nvidia-nat-mcp 1.3.0a20251012__py3-none-any.whl → 1.4.0a20251105__py3-none-any.whl

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.

Potentially problematic release.


This version of nvidia-nat-mcp might be problematic. Click here for more details.

@@ -54,6 +54,9 @@ class MCPAuthenticationFlowHandler(ConsoleAuthenticationFlowHandler):
54
54
  self._redirect_app: FastAPI | None = None
55
55
  self._server_lock = asyncio.Lock()
56
56
  self._oauth_client: AsyncOAuth2Client | None = None
57
+ self._redirect_host: str = "localhost" # Default host, will be overridden from config
58
+ self._redirect_port: int = 8000 # Default port, will be overridden from config
59
+ self._server_task: asyncio.Task | None = None
57
60
 
58
61
  async def authenticate(self, config: AuthProviderBaseConfig, method: AuthFlowType) -> AuthenticatedContext:
59
62
  """
@@ -88,6 +91,34 @@ class MCPAuthenticationFlowHandler(ConsoleAuthenticationFlowHandler):
88
91
  async def _handle_oauth2_auth_code_flow(self, cfg: OAuth2AuthCodeFlowProviderConfig) -> AuthenticatedContext:
89
92
  logger.info("Starting MCP OAuth2 authorization code flow")
90
93
 
94
+ # Extract and validate host and port from redirect_uri for callback server
95
+ from urllib.parse import urlparse
96
+ parsed_uri = urlparse(str(cfg.redirect_uri))
97
+
98
+ # Validate scheme/host and choose a safe non-privileged bind port
99
+ scheme = (parsed_uri.scheme or "http").lower()
100
+ if scheme not in ("http", "https"):
101
+ raise ValueError(f"redirect_uri must use http or https scheme, got '{scheme}'")
102
+
103
+ host = parsed_uri.hostname
104
+ if not host:
105
+ raise ValueError("redirect_uri must include a hostname, for example http://localhost:8000/auth/redirect")
106
+
107
+ # Never auto-bind to 80/443; default to 8000 when port is not specified
108
+ port = parsed_uri.port or 8000
109
+ if not (1 <= port <= 65535):
110
+ raise ValueError(f"Invalid redirect port: {port}. Expected 1-65535.")
111
+
112
+ if scheme == "https" and parsed_uri.port is None:
113
+ logger.warning(
114
+ "redirect_uri uses https without an explicit port; binding to %d (plain HTTP). "
115
+ "Terminate TLS at a reverse proxy and forward to this port.",
116
+ port)
117
+
118
+ self._redirect_host = host
119
+ self._redirect_port = port
120
+ logger.info("MCP redirect server will use %s:%d", self._redirect_host, self._redirect_port)
121
+
91
122
  state = secrets.token_urlsafe(16)
92
123
  flow_state = _FlowState()
93
124
  client = self.construct_oauth_client(cfg)
@@ -142,3 +173,36 @@ class MCPAuthenticationFlowHandler(ConsoleAuthenticationFlowHandler):
142
173
  "raw_token": token,
143
174
  },
144
175
  )
176
+
177
+ async def _start_redirect_server(self) -> None:
178
+ """
179
+ Override to use the host and port from redirect_uri config instead of hardcoded localhost:8000.
180
+
181
+ This allows MCP authentication to work with custom redirect hosts and ports
182
+ specified in the configuration.
183
+ """
184
+ # If the server is already running, do nothing
185
+ if self._server_controller:
186
+ return
187
+ try:
188
+ if not self._redirect_app:
189
+ raise RuntimeError("Redirect app not built.")
190
+
191
+ self._server_controller = _FastApiFrontEndController(self._redirect_app)
192
+
193
+ self._server_task = asyncio.create_task(
194
+ self._server_controller.start_server(host=self._redirect_host, port=self._redirect_port))
195
+ logger.debug("MCP redirect server starting on %s:%d", self._redirect_host, self._redirect_port)
196
+
197
+ # Wait for the server to bind (max ~10s)
198
+ start = asyncio.get_running_loop().time()
199
+ while True:
200
+ server = getattr(self._server_controller, "_server", None)
201
+ if server and getattr(server, "started", False):
202
+ break
203
+ if asyncio.get_running_loop().time() - start > 10:
204
+ raise RuntimeError("Redirect server did not report ready within 10s")
205
+ await asyncio.sleep(0.1)
206
+ except Exception as exc:
207
+ raise RuntimeError(
208
+ f"Failed to start MCP redirect server on {self._redirect_host}:{self._redirect_port}: {exc}") from exc
@@ -18,6 +18,7 @@ from pydantic import HttpUrl
18
18
  from pydantic import model_validator
19
19
 
20
20
  from nat.authentication.interfaces import AuthProviderBaseConfig
21
+ from nat.data_models.common import OptionalSecretStr
21
22
 
22
23
 
23
24
  class MCPOAuth2ProviderConfig(AuthProviderBaseConfig, name="mcp_oauth2"):
@@ -36,7 +37,8 @@ class MCPOAuth2ProviderConfig(AuthProviderBaseConfig, name="mcp_oauth2"):
36
37
 
37
38
  # Client registration (manual registration vs DCR)
38
39
  client_id: str | None = Field(default=None, description="OAuth2 client ID for pre-registered clients")
39
- client_secret: str | None = Field(default=None, description="OAuth2 client secret for pre-registered clients")
40
+ client_secret: OptionalSecretStr = Field(default=None,
41
+ description="OAuth2 client secret for pre-registered clients")
40
42
  enable_dynamic_registration: bool = Field(default=True,
41
43
  description="Enable OAuth2 Dynamic Client Registration (RFC 7591)")
42
44
  client_name: str = Field(default="NAT MCP Client", description="OAuth2 client name for dynamic registration")
nat/plugins/mcp/utils.py CHANGED
@@ -47,7 +47,7 @@ def model_from_mcp_schema(name: str, mcp_input_schema: dict) -> type[BaseModel]:
47
47
  "integer": int,
48
48
  "boolean": bool,
49
49
  "array": list,
50
- "null": None,
50
+ "null": type(None),
51
51
  "object": dict,
52
52
  }
53
53
 
@@ -58,51 +58,168 @@ def model_from_mcp_schema(name: str, mcp_input_schema: dict) -> type[BaseModel]:
58
58
  def _generate_valid_classname(class_name: str):
59
59
  return class_name.replace('_', ' ').replace('-', ' ').title().replace(' ', '')
60
60
 
61
- def _generate_field(field_name: str, field_properties: dict[str, Any]) -> tuple:
62
- json_type = field_properties.get("type", "string")
63
- enum_vals = field_properties.get("enum")
64
-
61
+ def _resolve_schema_type(schema: dict[str, Any], name: str) -> Any:
62
+ """
63
+ Recursively resolve a JSON schema to a Python type.
64
+ Handles nested anyOf/oneOf, arrays, objects, enums, and primitive types.
65
+ """
66
+ # Check for anyOf/oneOf first
67
+ any_of = schema.get("anyOf")
68
+ one_of = schema.get("oneOf")
69
+
70
+ if any_of or one_of:
71
+ union_schemas = any_of if any_of else one_of
72
+ resolved_type: Any = None
73
+
74
+ if union_schemas:
75
+ for sub_schema in union_schemas:
76
+ mapped = _resolve_schema_type(sub_schema, name)
77
+ if resolved_type is None:
78
+ resolved_type = mapped
79
+ elif mapped is not type(None):
80
+ # Don't add None here, handle separately
81
+ resolved_type = resolved_type | mapped
82
+ else:
83
+ # If we encounter null, combine with None at the end
84
+ resolved_type = resolved_type | None if resolved_type else type(None)
85
+
86
+ return resolved_type if resolved_type is not None else Any
87
+
88
+ # Handle enum values
89
+ enum_vals = schema.get("enum")
65
90
  if enum_vals:
66
- enum_name = f"{field_name.capitalize()}Enum"
67
- field_type = Enum(enum_name, {item: item for item in enum_vals})
68
-
69
- elif json_type == "object" and "properties" in field_properties:
70
- field_type = model_from_mcp_schema(name=field_name, mcp_input_schema=field_properties)
71
- elif json_type == "array" and "items" in field_properties:
72
- item_properties = field_properties.get("items", {})
73
- if item_properties.get("type") == "object":
74
- item_type = model_from_mcp_schema(name=field_name, mcp_input_schema=item_properties)
91
+ # Check if enum contains null
92
+ has_null = any(val is None or val == "null" for val in enum_vals)
93
+ # Filter out None/null values from enum
94
+ non_null_vals = [v for v in enum_vals if v is not None and v != "null"]
95
+
96
+ if non_null_vals:
97
+ enum_name = f"{name.capitalize()}Enum"
98
+ enum_type: Any = Enum(enum_name, {item: item for item in non_null_vals})
99
+ # If enum had null, make it a union with None
100
+ return enum_type | None if has_null else enum_type
101
+ elif has_null:
102
+ # Enum only contains null
103
+ return type(None)
75
104
  else:
76
- item_type = _type_map.get(item_properties.get("type", "string"), Any)
77
- field_type = list[item_type]
78
- elif isinstance(json_type, list):
79
- field_type = None
80
- for t in json_type:
81
- mapped = _type_map.get(t, Any)
82
- field_type = mapped if field_type is None else field_type | mapped
83
-
84
- return field_type, Field(
85
- default=field_properties.get("default", None if "null" in json_type else ...),
86
- description=field_properties.get("description", "")
87
- )
88
- else:
89
- field_type = _type_map.get(json_type, Any)
105
+ # Empty enum (shouldn't happen but handle gracefully)
106
+ return Any
107
+
108
+ schema_type = schema.get("type")
109
+
110
+ # Handle type as list (e.g., ["string", "integer", "null"])
111
+ if isinstance(schema_type, list):
112
+ list_type: Any = None
113
+ for t in schema_type:
114
+ if t == "array":
115
+ # Incorporate the mapped type of items
116
+ item_schema = schema.get("items", {})
117
+ if item_schema:
118
+ item_type = _resolve_schema_type(item_schema, name)
119
+ mapped = list[item_type]
120
+ else:
121
+ mapped = _type_map.get(t, Any)
122
+ elif t == "object":
123
+ # Incorporate the mapped type from properties
124
+ if "properties" in schema:
125
+ mapped = model_from_mcp_schema(name=name, mcp_input_schema=schema)
126
+ else:
127
+ mapped = _type_map.get(t, Any)
128
+ else:
129
+ mapped = _type_map.get(t, Any)
130
+
131
+ list_type = mapped if list_type is None else list_type | mapped
132
+ return list_type if list_type is not None else Any
133
+
134
+ # Handle null type
135
+ if schema_type == "null":
136
+ return type(None)
137
+
138
+ # Handle object type
139
+ if schema_type == "object" and "properties" in schema:
140
+ return model_from_mcp_schema(name=name, mcp_input_schema=schema)
141
+
142
+ # Handle array type
143
+ if schema_type == "array" and "items" in schema:
144
+ item_schema = schema.get("items", {})
145
+ # Recursively resolve item type (handles nested anyOf/oneOf)
146
+ item_type = _resolve_schema_type(item_schema, name)
147
+ return list[item_type]
148
+
149
+ # Handle primitive types
150
+ if schema_type is not None:
151
+ return _type_map.get(schema_type, Any)
152
+
153
+ return Any
154
+
155
+ def _has_null_in_type(field_properties: dict[str, Any]) -> bool:
156
+ """Check if a schema contains null as a valid type."""
157
+ # Check anyOf/oneOf for null
158
+ any_of = field_properties.get("anyOf")
159
+ one_of = field_properties.get("oneOf")
160
+ if any_of or one_of:
161
+ union_schemas = any_of if any_of else one_of
162
+ if union_schemas:
163
+ for schema in union_schemas:
164
+ if schema.get("type") == "null":
165
+ return True
166
+
167
+ # Check type list for null
168
+ json_type = field_properties.get("type")
169
+ if isinstance(json_type, list) and "null" in json_type:
170
+ return True
171
+
172
+ # Check enum for null (Python None or string "null")
173
+ enum_vals = field_properties.get("enum")
174
+ if enum_vals:
175
+ for val in enum_vals:
176
+ if val is None or val == "null":
177
+ return True
178
+
179
+ # Check const for null (Python None or string "null")
180
+ if "const" in field_properties:
181
+ const_val = field_properties.get("const")
182
+ if const_val is None or const_val == "null":
183
+ return True
184
+
185
+ return False
186
+
187
+ def _generate_field(field_name: str, field_properties: dict[str, Any]) -> tuple:
188
+ """
189
+ Generate a Pydantic field from JSON schema properties.
190
+ Uses _resolve_schema_type for type resolution and handles field-specific logic.
191
+ """
192
+ # Resolve the field type using the unified resolver
193
+ field_type = _resolve_schema_type(field_properties, field_name)
194
+
195
+ # Check if the type includes null
196
+ has_null = _has_null_in_type(field_properties)
90
197
 
91
198
  # Determine the default value based on whether the field is required
199
+ default_value = field_properties.get("default")
200
+
92
201
  if field_name in required_fields:
93
- # Field is required - use explicit default if provided, otherwise make it required
94
- default_value = field_properties.get("default", ...)
202
+ # Field is required - use explicit default if provided, otherwise use ... to enforce presence
203
+ if default_value is None and "default" not in field_properties:
204
+ # Required field without explicit default: always use ... even if nullable
205
+ default_value = ...
206
+ # Make the field type nullable if it allows null
207
+ if has_null:
208
+ field_type = field_type | None
95
209
  else:
96
210
  # Field is optional - use explicit default if provided, otherwise None
97
- default_value = field_properties.get("default", None)
98
- # Make the type optional if no default was provided
99
- if "default" not in field_properties:
211
+ if default_value is None:
212
+ default_value = None
213
+ # Make the type optional if no default was provided and not already nullable
214
+ if "default" not in field_properties and not has_null:
100
215
  field_type = field_type | None
101
216
 
217
+ # Handle nullable property (less common, but still supported)
102
218
  nullable = field_properties.get("nullable", False)
103
- description = field_properties.get("description", "")
219
+ if nullable and not has_null:
220
+ field_type = field_type | None
104
221
 
105
- field_type = field_type | None if nullable else field_type
222
+ description = field_properties.get("description", "")
106
223
 
107
224
  return field_type, Field(default=default_value, description=description)
108
225
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nvidia-nat-mcp
3
- Version: 1.3.0a20251012
3
+ Version: 1.4.0a20251105
4
4
  Summary: Subpackage for MCP client integration in NeMo Agent toolkit
5
5
  Author: NVIDIA Corporation
6
6
  Maintainer: NVIDIA Corporation
@@ -16,7 +16,7 @@ Requires-Python: <3.14,>=3.11
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE-3rd-party.txt
18
18
  License-File: LICENSE.md
19
- Requires-Dist: nvidia-nat==v1.3.0a20251012
19
+ Requires-Dist: nvidia-nat==v1.4.0a20251105
20
20
  Requires-Dist: aiorwlock~=1.5
21
21
  Requires-Dist: mcp~=1.14
22
22
  Dynamic: license-file
@@ -7,17 +7,17 @@ nat/plugins/mcp/exception_handler.py,sha256=4JVdZDJL4LyumZEcMIEBK2LYC6djuSMzqUhQ
7
7
  nat/plugins/mcp/exceptions.py,sha256=EGVOnYlui8xufm8dhJyPL1SUqBLnCGOTvRoeyNcmcWE,5980
8
8
  nat/plugins/mcp/register.py,sha256=HOT2Wl2isGuyFc7BUTi58-BbjI5-EtZMZo7stsv5pN4,831
9
9
  nat/plugins/mcp/tool.py,sha256=xNfBIF__ugJKFEjkYEM417wWM1PpuTaCMGtSFmxHSuA,6089
10
- nat/plugins/mcp/utils.py,sha256=4kNF5FJRiDUn-3fQcsvwvWtG6tYG1y4jU7vpptp0fsA,4522
10
+ nat/plugins/mcp/utils.py,sha256=dUIig7jeKz0ctb4o38jFGbe2uvM3DMR3PSJjfN_Lr5M,9111
11
11
  nat/plugins/mcp/auth/__init__.py,sha256=GUJrgGtpvyMUCjUBvR3faAdv-tZzbU9W-izgx9aMEQg,680
12
- nat/plugins/mcp/auth/auth_flow_handler.py,sha256=2JgK0aH-5ouQCd2ov0lDMJAD5ZWIQJ7SVcXaLArxn6Y,6010
12
+ nat/plugins/mcp/auth/auth_flow_handler.py,sha256=v21IK3IKZ2TLEP6wO9r-sJQiilWPq7Ry40M96SAxQFA,9125
13
13
  nat/plugins/mcp/auth/auth_provider.py,sha256=BgH66DlZgzhLDLO4cBERpHvNAmli5fMo_SCy11W9aBU,21251
14
- nat/plugins/mcp/auth/auth_provider_config.py,sha256=b1AaXzOuAkygKXAWSxMKWg8wfW8k33tmUUq6Dk5Mmwk,4038
14
+ nat/plugins/mcp/auth/auth_provider_config.py,sha256=ZdiUObYU_Oj8KDDZ-JqkS6kJgup5EBy2ZREUOBw_kkI,4143
15
15
  nat/plugins/mcp/auth/register.py,sha256=L2x69NjJPS4s6CCE5myzWVrWn3e_ttHyojmGXvBipMg,1228
16
16
  nat/plugins/mcp/auth/token_storage.py,sha256=aS13ZvEJXcYzkZ0GSbrSor4i5bpjD5BkXHQw1iywC9k,9240
17
- nvidia_nat_mcp-1.3.0a20251012.dist-info/licenses/LICENSE-3rd-party.txt,sha256=fOk5jMmCX9YoKWyYzTtfgl-SUy477audFC5hNY4oP7Q,284609
18
- nvidia_nat_mcp-1.3.0a20251012.dist-info/licenses/LICENSE.md,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
19
- nvidia_nat_mcp-1.3.0a20251012.dist-info/METADATA,sha256=VM-zKs9T5TYpPOeBahovycvuO1Vo_8OYUp6moYwwLVA,2319
20
- nvidia_nat_mcp-1.3.0a20251012.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
- nvidia_nat_mcp-1.3.0a20251012.dist-info/entry_points.txt,sha256=rYvUp4i-klBr3bVNh7zYOPXret704vTjvCk1qd7FooI,97
22
- nvidia_nat_mcp-1.3.0a20251012.dist-info/top_level.txt,sha256=8-CJ2cP6-f0ZReXe5Hzqp-5pvzzHz-5Ds5H2bGqh1-U,4
23
- nvidia_nat_mcp-1.3.0a20251012.dist-info/RECORD,,
17
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/licenses/LICENSE-3rd-party.txt,sha256=fOk5jMmCX9YoKWyYzTtfgl-SUy477audFC5hNY4oP7Q,284609
18
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/licenses/LICENSE.md,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
19
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/METADATA,sha256=cTjlqRzR05jFifL18XNTxYMjMLbI6rZNCQCWqB5FV54,2319
20
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/entry_points.txt,sha256=rYvUp4i-klBr3bVNh7zYOPXret704vTjvCk1qd7FooI,97
22
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/top_level.txt,sha256=8-CJ2cP6-f0ZReXe5Hzqp-5pvzzHz-5Ds5H2bGqh1-U,4
23
+ nvidia_nat_mcp-1.4.0a20251105.dist-info/RECORD,,