azurefunctions-agents-runtime 0.0.0.dev1__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.
@@ -0,0 +1,460 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+
6
+ from .arm import ArmClient, DataPlaneClient
7
+
8
+
9
+ @dataclass
10
+ class ParsedParameter:
11
+ name: str
12
+ location: str # "path", "query", "header", "body"
13
+ type: str
14
+ required: bool
15
+ description: str
16
+ format: str | None = None
17
+ enum: list[str] | None = None
18
+ default: object = None
19
+
20
+
21
+ @dataclass
22
+ class ParsedOperation:
23
+ operation_id: str
24
+ method: str
25
+ path: str
26
+ summary: str
27
+ description: str
28
+ parameters: list[ParsedParameter] = field(default_factory=list)
29
+ body_properties: list[ParsedParameter] = field(default_factory=list)
30
+ body_required_fields: list[str] = field(default_factory=list)
31
+ internal_params: list[ParsedParameter] = field(default_factory=list)
32
+
33
+
34
+ @dataclass
35
+ class ConnectionInfo:
36
+ resource_id: str
37
+ name: str
38
+ api_name: str
39
+ display_name: str
40
+ status: str
41
+ location: str
42
+ operations: list[ParsedOperation] = field(default_factory=list)
43
+ connection_runtime_url: str | None = None
44
+
45
+
46
+ def is_v2_connection(connection_id: str) -> bool:
47
+ """Return True if the connection ID is a V2 (gateway) connection."""
48
+ lower = connection_id.lower()
49
+ return "/aigateways/" in lower or "/connectorgateways/" in lower
50
+
51
+
52
+ def _resolve_ref(ref: str, root: dict) -> dict:
53
+ """Resolve a $ref pointer like '#/definitions/Foo' against the swagger root."""
54
+ parts = ref.lstrip("#/").split("/")
55
+ result = root
56
+ for part in parts:
57
+ result = result.get(part, {})
58
+ return result
59
+
60
+
61
+ def _resolve_schema(schema: dict, swagger: dict, depth: int = 0) -> dict:
62
+ """Resolve a schema, following $ref if present."""
63
+ if "$ref" in schema:
64
+ return _resolve_ref(schema["$ref"], swagger)
65
+ return schema
66
+
67
+
68
+ def _extract_body_properties(
69
+ body_schema: dict, swagger: dict, max_depth: int = 2, depth: int = 0
70
+ ) -> tuple[list[ParsedParameter], list[str]]:
71
+ """Flatten body schema properties into a list of ParsedParameters."""
72
+ resolved = _resolve_schema(body_schema, swagger)
73
+ properties = resolved.get("properties", {})
74
+ required_fields = resolved.get("required", [])
75
+ params = []
76
+
77
+ internal_params = []
78
+ for prop_name, prop_schema in properties.items():
79
+ prop_resolved = _resolve_schema(prop_schema, swagger)
80
+ visibility = prop_resolved.get("x-ms-visibility", "")
81
+ if visibility == "internal":
82
+ if prop_resolved.get("default") is not None:
83
+ internal_params.append(ParsedParameter(
84
+ name=prop_name,
85
+ location="body",
86
+ type=prop_resolved.get("type", "string"),
87
+ required=False,
88
+ description="",
89
+ default=prop_resolved.get("default"),
90
+ ))
91
+ continue
92
+
93
+ prop_type = prop_resolved.get("type", "string")
94
+
95
+ # Flatten nested objects: extract their properties with dot-separated names
96
+ if prop_type == "object" and depth < max_depth:
97
+ nested_props = prop_resolved.get("properties", {})
98
+ nested_required = prop_resolved.get("required", [])
99
+ if nested_props:
100
+ for nested_name, nested_schema in nested_props.items():
101
+ nested_resolved = _resolve_schema(nested_schema, swagger)
102
+ nested_vis = nested_resolved.get("x-ms-visibility", "")
103
+ if nested_vis == "internal":
104
+ continue
105
+ nested_type = nested_resolved.get("type", "string")
106
+ if nested_type in ("object", "array") and depth + 1 >= max_depth:
107
+ nested_type = "string"
108
+ flat_name = f"{prop_name}.{nested_name}"
109
+ params.append(ParsedParameter(
110
+ name=flat_name,
111
+ location="body",
112
+ type=nested_type,
113
+ required=nested_name in nested_required,
114
+ description=nested_resolved.get("description", nested_resolved.get("x-ms-summary", nested_resolved.get("title", ""))),
115
+ format=nested_resolved.get("format"),
116
+ enum=nested_resolved.get("enum"),
117
+ default=nested_resolved.get("default"),
118
+ ))
119
+ if nested_name in nested_required:
120
+ required_fields.append(flat_name)
121
+ continue
122
+
123
+ if prop_type in ("object", "array") and depth >= max_depth:
124
+ prop_type = "string" # serialize as JSON string
125
+
126
+ params.append(ParsedParameter(
127
+ name=prop_name,
128
+ location="body",
129
+ type=prop_type,
130
+ required=prop_name in required_fields,
131
+ description=prop_resolved.get("description", prop_resolved.get("x-ms-summary", prop_resolved.get("title", ""))),
132
+ format=prop_resolved.get("format"),
133
+ enum=prop_resolved.get("enum"),
134
+ default=prop_resolved.get("default"),
135
+ ))
136
+
137
+ return params, required_fields, internal_params
138
+
139
+
140
+ async def _resolve_dynamic_schema(
141
+ arm: ArmClient, resource_id: str, swagger: dict, dynamic_schema: dict, op: dict,
142
+ *, data_plane_client: DataPlaneClient | None = None, connection_runtime_url: str | None = None,
143
+ ) -> dict | None:
144
+ """Resolve an x-ms-dynamic-schema by calling the referenced operation."""
145
+ op_id = dynamic_schema.get("operationId")
146
+ if not op_id:
147
+ return None
148
+
149
+ # Find the path for the referenced operation
150
+ schema_path = None
151
+ schema_method = None
152
+ for p, methods in swagger.get("paths", {}).items():
153
+ for m, o in methods.items():
154
+ if isinstance(o, dict) and o.get("operationId") == op_id:
155
+ schema_path = p
156
+ schema_method = m
157
+ break
158
+ if schema_path:
159
+ break
160
+
161
+ if not schema_path:
162
+ return None
163
+
164
+ # Strip /{connectionId} from path
165
+ invoke_path = re.sub(r"^/\{connectionId\}", "", schema_path, flags=re.IGNORECASE)
166
+
167
+ # Build query/path params from the dynamic schema's parameters
168
+ params = dynamic_schema.get("parameters", {})
169
+ for param_name, param_val in params.items():
170
+ if isinstance(param_val, dict) and "parameter" in param_val:
171
+ ref_param = param_val["parameter"]
172
+ defaults = {"poster": "User", "location": "Channel", "recipientType": "Channel"}
173
+ param_val = defaults.get(ref_param, "")
174
+ invoke_path = invoke_path.replace(f"{{{param_name}}}", str(param_val))
175
+
176
+ try:
177
+ if data_plane_client and connection_runtime_url:
178
+ # V2: direct HTTP to data plane
179
+ url = f"{connection_runtime_url.rstrip('/')}{invoke_path}"
180
+ result = await data_plane_client.request(schema_method.upper(), url)
181
+ value_path = dynamic_schema.get("value-path", "schema")
182
+ return result.get(value_path, result)
183
+ else:
184
+ # V1: dynamicInvoke via ARM
185
+ result = await arm.post(
186
+ f"{resource_id}/dynamicInvoke",
187
+ body={"request": {"method": schema_method.upper(), "path": invoke_path}}
188
+ )
189
+ response = result.get("response", {})
190
+ body = response.get("body", {})
191
+ value_path = dynamic_schema.get("value-path", "schema")
192
+ return body.get(value_path, body)
193
+ except Exception:
194
+ return None
195
+
196
+
197
+ async def _parse_operations(
198
+ swagger: dict, arm: ArmClient, resource_id: str,
199
+ *, data_plane_client: DataPlaneClient | None = None, connection_runtime_url: str | None = None,
200
+ ) -> list[ParsedOperation]:
201
+ """Parse Swagger paths into a list of ParsedOperations."""
202
+ paths = swagger.get("paths", {})
203
+ operations: list[ParsedOperation] = []
204
+ seen_families: dict[str, tuple[ParsedOperation, int]] = {}
205
+
206
+ for path, methods in paths.items():
207
+ if "$subscriptions" in path:
208
+ continue
209
+
210
+ for method, op in methods.items():
211
+ if method in ("parameters", "x-ms-notification-content"):
212
+ continue
213
+ if not isinstance(op, dict):
214
+ continue
215
+
216
+ if op.get("x-ms-trigger"):
217
+ continue
218
+ if op.get("deprecated"):
219
+ continue
220
+ if method.lower() == "delete":
221
+ continue
222
+
223
+ visibility = op.get("x-ms-visibility", "")
224
+ if visibility == "internal":
225
+ continue
226
+
227
+ operation_id = op.get("operationId", f"{method}_{path}")
228
+
229
+ if operation_id.startswith("mcp_") or operation_id == "HttpRequest":
230
+ continue
231
+
232
+ summary = op.get("summary", "")
233
+ description = op.get("description", "")
234
+
235
+ params = []
236
+ internal_params = []
237
+ body_props = []
238
+ body_required: list[str] = []
239
+
240
+ for param in op.get("parameters", []):
241
+ if "$ref" in param:
242
+ param = _resolve_ref(param["$ref"], swagger)
243
+
244
+ param_in = param.get("in", "")
245
+ if param_in == "body":
246
+ schema = param.get("schema", {})
247
+ resolved_schema = _resolve_schema(schema, swagger)
248
+ dynamic = resolved_schema.get("x-ms-dynamic-schema")
249
+ if dynamic and not resolved_schema.get("properties"):
250
+ dyn_schema = await _resolve_dynamic_schema(
251
+ arm, resource_id, swagger, dynamic, op,
252
+ data_plane_client=data_plane_client,
253
+ connection_runtime_url=connection_runtime_url,
254
+ )
255
+ if dyn_schema:
256
+ body_props, body_required, body_internal = _extract_body_properties(
257
+ {"properties": dyn_schema.get("properties", {}), "required": dyn_schema.get("required", [])},
258
+ swagger,
259
+ )
260
+ internal_params.extend(body_internal)
261
+ else:
262
+ body_props, body_required, body_internal = _extract_body_properties(schema, swagger)
263
+ internal_params.extend(body_internal)
264
+ else:
265
+ body_props, body_required, body_internal = _extract_body_properties(schema, swagger)
266
+ internal_params.extend(body_internal)
267
+ continue
268
+
269
+ if param.get("name") == "connectionId":
270
+ continue
271
+
272
+ param_visibility = param.get("x-ms-visibility", "")
273
+ if param_visibility == "internal":
274
+ if param.get("default") is not None:
275
+ internal_params.append(ParsedParameter(
276
+ name=param.get("name", ""),
277
+ location=param_in,
278
+ type=param.get("type", "string"),
279
+ required=False,
280
+ description="",
281
+ default=param.get("default"),
282
+ ))
283
+ continue
284
+
285
+ params.append(ParsedParameter(
286
+ name=param.get("name", ""),
287
+ location=param_in,
288
+ type=param.get("type", "string"),
289
+ required=param.get("required", False),
290
+ description=param.get("description", param.get("x-ms-summary", "")),
291
+ format=param.get("format"),
292
+ enum=param.get("enum"),
293
+ default=param.get("default"),
294
+ ))
295
+
296
+ parsed = ParsedOperation(
297
+ operation_id=operation_id,
298
+ method=method.upper(),
299
+ path=path,
300
+ summary=summary,
301
+ description=description,
302
+ parameters=params,
303
+ body_properties=body_props,
304
+ body_required_fields=body_required,
305
+ internal_params=internal_params,
306
+ )
307
+
308
+ annotation = op.get("x-ms-api-annotation", {})
309
+ family = annotation.get("family")
310
+ new_rev = annotation.get("revision", 0)
311
+ if family:
312
+ existing = seen_families.get(family)
313
+ if existing is None:
314
+ seen_families[family] = (parsed, new_rev)
315
+ operations.append(parsed)
316
+ else:
317
+ existing_op, existing_rev = existing
318
+ if new_rev > existing_rev:
319
+ operations.remove(existing_op)
320
+ seen_families[family] = (parsed, new_rev)
321
+ operations.append(parsed)
322
+ else:
323
+ operations.append(parsed)
324
+
325
+ return operations
326
+
327
+
328
+ def _parse_resource_id(resource_id: str) -> dict:
329
+ """Extract subscription, resource group, and name from a V1 connection resource ID."""
330
+ pattern = (
331
+ r"/subscriptions/(?P<subscription>[^/]+)"
332
+ r"/resourceGroups/(?P<resource_group>[^/]+)"
333
+ r"/providers/Microsoft\.Web/connections/(?P<name>[^/]+)"
334
+ )
335
+ match = re.search(pattern, resource_id, re.IGNORECASE)
336
+ if not match:
337
+ raise ValueError(f"Invalid V1 connection resource ID: {resource_id}")
338
+ return match.groupdict()
339
+
340
+
341
+ def _parse_v2_resource_id(resource_id: str) -> dict:
342
+ """Extract subscription, resource group, gateway type, gateway, and name from a V2 connection resource ID."""
343
+ pattern = (
344
+ r"/subscriptions/(?P<subscription>[^/]+)"
345
+ r"/resourceGroups/(?P<resource_group>[^/]+)"
346
+ r"/providers/Microsoft\.Web/(?P<gateway_type>aigateways|connectorGateways)/(?P<gateway>[^/]+)"
347
+ r"/connections/(?P<name>[^/]+)"
348
+ )
349
+ match = re.search(pattern, resource_id, re.IGNORECASE)
350
+ if not match:
351
+ raise ValueError(f"Invalid V2 connection resource ID: {resource_id}")
352
+ return match.groupdict()
353
+
354
+
355
+ _V2_API_VERSIONS = {
356
+ "aigateways": "2026-03-01-preview",
357
+ "connectorgateways": "2026-05-01-preview",
358
+ }
359
+
360
+
361
+ async def load_connection(
362
+ arm: ArmClient, resource_id: str,
363
+ *, data_plane_client: DataPlaneClient | None = None,
364
+ ) -> ConnectionInfo:
365
+ """Fetch connection metadata and Swagger spec, return a ConnectionInfo with parsed operations.
366
+
367
+ Automatically detects V1 vs V2 connections based on the resource ID format.
368
+ """
369
+ if is_v2_connection(resource_id):
370
+ return await _load_v2_connection(arm, resource_id, data_plane_client=data_plane_client)
371
+ return await _load_v1_connection(arm, resource_id)
372
+
373
+
374
+ async def _load_v1_connection(arm: ArmClient, resource_id: str) -> ConnectionInfo:
375
+ """Load a V1 connection (Microsoft.Web/connections)."""
376
+ conn_data = await arm.get(resource_id)
377
+ props = conn_data.get("properties", {})
378
+ api_name = props.get("api", {}).get("name", "")
379
+ display_name = props.get("displayName", "")
380
+ statuses = props.get("statuses") or [{}]
381
+ status = props.get("overallStatus", statuses[0].get("status", "Unknown"))
382
+ location = conn_data.get("location", "")
383
+
384
+ parts = _parse_resource_id(resource_id)
385
+ swagger_path = (
386
+ f"/subscriptions/{parts['subscription']}"
387
+ f"/providers/Microsoft.Web/locations/{location}"
388
+ f"/managedApis/{api_name}"
389
+ )
390
+ api_data = await arm.get(swagger_path, params={"export": "true"})
391
+ swagger = api_data.get("properties", {}).get("swagger", {})
392
+ if not swagger.get("paths"):
393
+ swagger = api_data
394
+
395
+ operations = await _parse_operations(swagger, arm, resource_id)
396
+
397
+ return ConnectionInfo(
398
+ resource_id=resource_id,
399
+ name=parts["name"],
400
+ api_name=api_name,
401
+ display_name=display_name,
402
+ status=status,
403
+ location=location,
404
+ operations=operations,
405
+ )
406
+
407
+
408
+ async def _load_v2_connection(
409
+ arm: ArmClient, resource_id: str,
410
+ *, data_plane_client: DataPlaneClient | None = None,
411
+ ) -> ConnectionInfo:
412
+ """Load a V2 connection (Microsoft.Web/aigateways or connectorGateways)."""
413
+ parts = _parse_v2_resource_id(resource_id)
414
+ gateway_type = parts["gateway_type"]
415
+ api_version = _V2_API_VERSIONS.get(gateway_type.lower(), "2026-05-01-preview")
416
+
417
+ # Get connection metadata
418
+ conn_data = await arm.get(resource_id, api_version=api_version)
419
+ props = conn_data.get("properties", {})
420
+ api_name = props.get("connectorName", "")
421
+ display_name = props.get("displayName", "")
422
+ status = props.get("overallStatus", "Unknown")
423
+ connection_runtime_url = props.get("connectionRuntimeUrl", "")
424
+
425
+ # Get location from the parent gateway
426
+ gateway_path = (
427
+ f"/subscriptions/{parts['subscription']}"
428
+ f"/resourceGroups/{parts['resource_group']}"
429
+ f"/providers/Microsoft.Web/{gateway_type}/{parts['gateway']}"
430
+ )
431
+ gateway_data = await arm.get(gateway_path, api_version=api_version)
432
+ location = gateway_data.get("location", "")
433
+
434
+ # Swagger uses the same managed API endpoint as V1
435
+ swagger_path = (
436
+ f"/subscriptions/{parts['subscription']}"
437
+ f"/providers/Microsoft.Web/locations/{location}"
438
+ f"/managedApis/{api_name}"
439
+ )
440
+ api_data = await arm.get(swagger_path, params={"export": "true"})
441
+ swagger = api_data.get("properties", {}).get("swagger", {})
442
+ if not swagger.get("paths"):
443
+ swagger = api_data
444
+
445
+ operations = await _parse_operations(
446
+ swagger, arm, resource_id,
447
+ data_plane_client=data_plane_client,
448
+ connection_runtime_url=connection_runtime_url,
449
+ )
450
+
451
+ return ConnectionInfo(
452
+ resource_id=resource_id,
453
+ name=parts["name"],
454
+ api_name=api_name,
455
+ display_name=display_name,
456
+ status=status,
457
+ location=location,
458
+ operations=operations,
459
+ connection_runtime_url=connection_runtime_url,
460
+ )
@@ -0,0 +1,87 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from typing import Any, Dict, Optional
5
+
6
+ from copilot.session import MCPLocalServerConfig, MCPRemoteServerConfig, MCPServerConfig
7
+
8
+ from .config import get_app_root
9
+
10
+ _MCP_SERVERS_CACHE: Optional[Dict[str, MCPServerConfig]] = None
11
+
12
+
13
+ def _parse_mcp_server_config(server: Dict[str, Any]) -> Optional[MCPServerConfig]:
14
+ server_type = str(server.get("type", "")).lower()
15
+
16
+ if "command" in server or server_type == "local":
17
+ local_config: MCPLocalServerConfig = {
18
+ "type": "local",
19
+ "command": str(server.get("command", "")),
20
+ "args": server.get("args", []),
21
+ "env": server.get("env", {}),
22
+ "tools": server.get("tools", ["*"]),
23
+ }
24
+ if not local_config["command"]:
25
+ return None
26
+ return local_config
27
+
28
+ if "url" in server or server_type in {"http", "sse"}:
29
+ remote_type = server_type if server_type in {"http", "sse"} else "http"
30
+ remote_config: MCPRemoteServerConfig = {
31
+ "type": remote_type, # type: ignore
32
+ "url": str(server.get("url", "")),
33
+ "headers": server.get("headers"),
34
+ "tools": server.get("tools", ["*"]),
35
+ }
36
+ if not remote_config["url"]:
37
+ return None
38
+ return remote_config
39
+
40
+ return None
41
+
42
+
43
+ def _load_mcp_servers_from_file() -> Dict[str, MCPServerConfig]:
44
+ app_root = str(get_app_root())
45
+ candidates = [
46
+ os.path.join(app_root, ".vscode", "mcp.json"),
47
+ os.path.join(app_root, "mcp.json"),
48
+ ]
49
+
50
+ for path in candidates:
51
+ if not os.path.exists(path):
52
+ continue
53
+
54
+ try:
55
+ with open(path, "r", encoding="utf-8") as f:
56
+ data = json.load(f)
57
+ except Exception as e:
58
+ logging.warning(f"Failed to read MCP config from {path}: {e}")
59
+ continue
60
+
61
+ servers = data.get("servers", {})
62
+ if not isinstance(servers, dict):
63
+ logging.warning(f"Invalid MCP config in {path}: 'servers' must be an object")
64
+ return {}
65
+
66
+ parsed_servers: Dict[str, MCPServerConfig] = {}
67
+ for name, config in servers.items():
68
+ if not isinstance(name, str) or not isinstance(config, dict):
69
+ continue
70
+ parsed = _parse_mcp_server_config(config)
71
+ if parsed is not None:
72
+ parsed_servers[name] = parsed
73
+
74
+ if parsed_servers:
75
+ logging.info(f"Loaded {len(parsed_servers)} MCP server(s) from {path}")
76
+ else:
77
+ logging.info(f"No valid MCP servers found in {path}")
78
+ return parsed_servers
79
+
80
+ return {}
81
+
82
+
83
+ def get_cached_mcp_servers() -> Dict[str, MCPServerConfig]:
84
+ global _MCP_SERVERS_CACHE
85
+ if _MCP_SERVERS_CACHE is None:
86
+ _MCP_SERVERS_CACHE = _load_mcp_servers_from_file()
87
+ return _MCP_SERVERS_CACHE