kailash 0.6.3__py3-none-any.whl → 0.6.5__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.
- kailash/__init__.py +3 -3
- kailash/api/custom_nodes_secure.py +3 -3
- kailash/api/gateway.py +1 -1
- kailash/api/studio.py +1 -1
- kailash/api/workflow_api.py +2 -2
- kailash/core/resilience/bulkhead.py +475 -0
- kailash/core/resilience/circuit_breaker.py +92 -10
- kailash/core/resilience/health_monitor.py +578 -0
- kailash/edge/discovery.py +86 -0
- kailash/mcp_server/__init__.py +309 -33
- kailash/mcp_server/advanced_features.py +1022 -0
- kailash/mcp_server/ai_registry_server.py +27 -2
- kailash/mcp_server/auth.py +789 -0
- kailash/mcp_server/client.py +645 -378
- kailash/mcp_server/discovery.py +1593 -0
- kailash/mcp_server/errors.py +673 -0
- kailash/mcp_server/oauth.py +1727 -0
- kailash/mcp_server/protocol.py +1126 -0
- kailash/mcp_server/registry_integration.py +587 -0
- kailash/mcp_server/server.py +1228 -96
- kailash/mcp_server/transports.py +1169 -0
- kailash/mcp_server/utils/__init__.py +6 -1
- kailash/mcp_server/utils/cache.py +250 -7
- kailash/middleware/auth/auth_manager.py +3 -3
- kailash/middleware/communication/api_gateway.py +1 -1
- kailash/middleware/communication/realtime.py +1 -1
- kailash/middleware/mcp/enhanced_server.py +1 -1
- kailash/nodes/__init__.py +2 -0
- kailash/nodes/admin/audit_log.py +6 -6
- kailash/nodes/admin/permission_check.py +8 -8
- kailash/nodes/admin/role_management.py +32 -28
- kailash/nodes/admin/schema.sql +6 -1
- kailash/nodes/admin/schema_manager.py +13 -13
- kailash/nodes/admin/security_event.py +15 -15
- kailash/nodes/admin/tenant_isolation.py +3 -3
- kailash/nodes/admin/transaction_utils.py +3 -3
- kailash/nodes/admin/user_management.py +21 -21
- kailash/nodes/ai/a2a.py +11 -11
- kailash/nodes/ai/ai_providers.py +9 -12
- kailash/nodes/ai/embedding_generator.py +13 -14
- kailash/nodes/ai/intelligent_agent_orchestrator.py +19 -19
- kailash/nodes/ai/iterative_llm_agent.py +2 -2
- kailash/nodes/ai/llm_agent.py +210 -33
- kailash/nodes/ai/self_organizing.py +2 -2
- kailash/nodes/alerts/discord.py +4 -4
- kailash/nodes/api/graphql.py +6 -6
- kailash/nodes/api/http.py +10 -10
- kailash/nodes/api/rate_limiting.py +4 -4
- kailash/nodes/api/rest.py +15 -15
- kailash/nodes/auth/mfa.py +3 -3
- kailash/nodes/auth/risk_assessment.py +2 -2
- kailash/nodes/auth/session_management.py +5 -5
- kailash/nodes/auth/sso.py +143 -0
- kailash/nodes/base.py +8 -2
- kailash/nodes/base_async.py +16 -2
- kailash/nodes/base_with_acl.py +2 -2
- kailash/nodes/cache/__init__.py +9 -0
- kailash/nodes/cache/cache.py +1172 -0
- kailash/nodes/cache/cache_invalidation.py +874 -0
- kailash/nodes/cache/redis_pool_manager.py +595 -0
- kailash/nodes/code/async_python.py +2 -1
- kailash/nodes/code/python.py +194 -30
- kailash/nodes/compliance/data_retention.py +6 -6
- kailash/nodes/compliance/gdpr.py +5 -5
- kailash/nodes/data/__init__.py +10 -0
- kailash/nodes/data/async_sql.py +1956 -129
- kailash/nodes/data/optimistic_locking.py +906 -0
- kailash/nodes/data/readers.py +8 -8
- kailash/nodes/data/redis.py +378 -0
- kailash/nodes/data/sql.py +314 -3
- kailash/nodes/data/streaming.py +21 -0
- kailash/nodes/enterprise/__init__.py +8 -0
- kailash/nodes/enterprise/audit_logger.py +285 -0
- kailash/nodes/enterprise/batch_processor.py +22 -3
- kailash/nodes/enterprise/data_lineage.py +1 -1
- kailash/nodes/enterprise/mcp_executor.py +205 -0
- kailash/nodes/enterprise/service_discovery.py +150 -0
- kailash/nodes/enterprise/tenant_assignment.py +108 -0
- kailash/nodes/logic/async_operations.py +2 -2
- kailash/nodes/logic/convergence.py +1 -1
- kailash/nodes/logic/operations.py +1 -1
- kailash/nodes/monitoring/__init__.py +11 -1
- kailash/nodes/monitoring/health_check.py +456 -0
- kailash/nodes/monitoring/log_processor.py +817 -0
- kailash/nodes/monitoring/metrics_collector.py +627 -0
- kailash/nodes/monitoring/performance_benchmark.py +137 -11
- kailash/nodes/rag/advanced.py +7 -7
- kailash/nodes/rag/agentic.py +49 -2
- kailash/nodes/rag/conversational.py +3 -3
- kailash/nodes/rag/evaluation.py +3 -3
- kailash/nodes/rag/federated.py +3 -3
- kailash/nodes/rag/graph.py +3 -3
- kailash/nodes/rag/multimodal.py +3 -3
- kailash/nodes/rag/optimized.py +5 -5
- kailash/nodes/rag/privacy.py +3 -3
- kailash/nodes/rag/query_processing.py +6 -6
- kailash/nodes/rag/realtime.py +1 -1
- kailash/nodes/rag/registry.py +1 -1
- kailash/nodes/rag/router.py +1 -1
- kailash/nodes/rag/similarity.py +7 -7
- kailash/nodes/rag/strategies.py +4 -4
- kailash/nodes/security/abac_evaluator.py +6 -6
- kailash/nodes/security/behavior_analysis.py +5 -5
- kailash/nodes/security/credential_manager.py +1 -1
- kailash/nodes/security/rotating_credentials.py +11 -11
- kailash/nodes/security/threat_detection.py +8 -8
- kailash/nodes/testing/credential_testing.py +2 -2
- kailash/nodes/transform/processors.py +5 -5
- kailash/runtime/local.py +163 -9
- kailash/runtime/parameter_injection.py +425 -0
- kailash/runtime/parameter_injector.py +657 -0
- kailash/runtime/testing.py +2 -2
- kailash/testing/fixtures.py +2 -2
- kailash/workflow/builder.py +99 -14
- kailash/workflow/builder_improvements.py +207 -0
- kailash/workflow/input_handling.py +170 -0
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/METADATA +22 -9
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/RECORD +122 -95
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/WHEEL +0 -0
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/entry_points.txt +0 -0
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.6.3.dist-info → kailash-0.6.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1022 @@
|
|
1
|
+
"""
|
2
|
+
Advanced MCP Features Implementation.
|
3
|
+
|
4
|
+
This module implements advanced MCP features including structured outputs,
|
5
|
+
resource templates, streaming support, progress reporting, and other
|
6
|
+
sophisticated protocol features that extend the basic MCP functionality.
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Structured tool outputs with JSON Schema validation
|
10
|
+
- Resource templates with URI template patterns
|
11
|
+
- Resource subscriptions and change notifications
|
12
|
+
- Multi-modal content support (text, images, audio)
|
13
|
+
- Binary resource handling with Base64 encoding
|
14
|
+
- Progress reporting for long-running operations
|
15
|
+
- Request cancellation and cleanup
|
16
|
+
- Tool annotations and metadata
|
17
|
+
- Content streaming for large responses
|
18
|
+
- Elicitation system for interactive user input
|
19
|
+
|
20
|
+
Examples:
|
21
|
+
Structured tool with validation:
|
22
|
+
|
23
|
+
>>> from kailash.mcp_server.advanced_features import StructuredTool
|
24
|
+
>>>
|
25
|
+
>>> @StructuredTool(
|
26
|
+
... output_schema={
|
27
|
+
... "type": "object",
|
28
|
+
... "properties": {
|
29
|
+
... "results": {"type": "array"},
|
30
|
+
... "count": {"type": "integer"}
|
31
|
+
... },
|
32
|
+
... "required": ["results", "count"]
|
33
|
+
... }
|
34
|
+
... )
|
35
|
+
... def search_tool(query: str) -> dict:
|
36
|
+
... return {"results": ["item1", "item2"], "count": 2}
|
37
|
+
|
38
|
+
Resource template with dynamic URIs:
|
39
|
+
|
40
|
+
>>> from kailash.mcp_server.advanced_features import ResourceTemplate
|
41
|
+
>>>
|
42
|
+
>>> template = ResourceTemplate(
|
43
|
+
... uri_template="files://{path}",
|
44
|
+
... name="File Access",
|
45
|
+
... description="Access files by path"
|
46
|
+
... )
|
47
|
+
>>>
|
48
|
+
>>> # Subscribe to resource changes
|
49
|
+
>>> subscription = await template.subscribe(
|
50
|
+
... uri="files://documents/report.pdf",
|
51
|
+
... callback=lambda change: print(f"File changed: {change}")
|
52
|
+
... )
|
53
|
+
|
54
|
+
Multi-modal content:
|
55
|
+
|
56
|
+
>>> from kailash.mcp_server.advanced_features import MultiModalContent
|
57
|
+
>>>
|
58
|
+
>>> content = MultiModalContent()
|
59
|
+
>>> content.add_text("Here is the analysis:")
|
60
|
+
>>> content.add_image(image_data, "image/png")
|
61
|
+
>>> content.add_resource("files://data.csv", "text/csv")
|
62
|
+
"""
|
63
|
+
|
64
|
+
import asyncio
|
65
|
+
import base64
|
66
|
+
import json
|
67
|
+
import logging
|
68
|
+
import mimetypes
|
69
|
+
import time
|
70
|
+
import uuid
|
71
|
+
from abc import ABC, abstractmethod
|
72
|
+
from dataclasses import asdict, dataclass, field
|
73
|
+
from enum import Enum
|
74
|
+
from pathlib import Path
|
75
|
+
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Union
|
76
|
+
from urllib.parse import urlparse
|
77
|
+
|
78
|
+
import jsonschema
|
79
|
+
|
80
|
+
from .errors import MCPError, MCPErrorCode, ValidationError
|
81
|
+
from .protocol import ProgressToken, get_protocol_manager
|
82
|
+
|
83
|
+
logger = logging.getLogger(__name__)
|
84
|
+
|
85
|
+
|
86
|
+
class ContentType(Enum):
|
87
|
+
"""Content types for multi-modal content."""
|
88
|
+
|
89
|
+
TEXT = "text"
|
90
|
+
IMAGE = "image"
|
91
|
+
AUDIO = "audio"
|
92
|
+
RESOURCE = "resource"
|
93
|
+
ANNOTATION = "annotation"
|
94
|
+
|
95
|
+
|
96
|
+
class ChangeType(Enum):
|
97
|
+
"""Resource change types."""
|
98
|
+
|
99
|
+
CREATED = "created"
|
100
|
+
UPDATED = "updated"
|
101
|
+
DELETED = "deleted"
|
102
|
+
|
103
|
+
|
104
|
+
@dataclass
|
105
|
+
class Content:
|
106
|
+
"""Multi-modal content item."""
|
107
|
+
|
108
|
+
type: ContentType
|
109
|
+
data: Any
|
110
|
+
mime_type: Optional[str] = None
|
111
|
+
annotations: Dict[str, Any] = field(default_factory=dict)
|
112
|
+
|
113
|
+
def to_dict(self) -> Dict[str, Any]:
|
114
|
+
"""Convert to dictionary format."""
|
115
|
+
result = {
|
116
|
+
"type": self.type.value,
|
117
|
+
}
|
118
|
+
|
119
|
+
if self.type == ContentType.TEXT:
|
120
|
+
result["text"] = self.data
|
121
|
+
elif self.type == ContentType.IMAGE:
|
122
|
+
result["data"] = self.data
|
123
|
+
if self.mime_type:
|
124
|
+
result["mimeType"] = self.mime_type
|
125
|
+
elif self.type == ContentType.AUDIO:
|
126
|
+
result["data"] = self.data
|
127
|
+
if self.mime_type:
|
128
|
+
result["mimeType"] = self.mime_type
|
129
|
+
elif self.type == ContentType.RESOURCE:
|
130
|
+
result["resource"] = self.data
|
131
|
+
elif self.type == ContentType.ANNOTATION:
|
132
|
+
result["annotation"] = self.data
|
133
|
+
|
134
|
+
if self.annotations:
|
135
|
+
result["annotations"] = self.annotations
|
136
|
+
|
137
|
+
return result
|
138
|
+
|
139
|
+
|
140
|
+
@dataclass
|
141
|
+
class ResourceChange:
|
142
|
+
"""Resource change notification."""
|
143
|
+
|
144
|
+
uri: str
|
145
|
+
change_type: ChangeType
|
146
|
+
content: Optional[Dict[str, Any]] = None
|
147
|
+
timestamp: float = field(default_factory=time.time)
|
148
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
149
|
+
|
150
|
+
def to_dict(self) -> Dict[str, Any]:
|
151
|
+
"""Convert to dictionary format."""
|
152
|
+
return {
|
153
|
+
"uri": self.uri,
|
154
|
+
"type": self.change_type.value,
|
155
|
+
"content": self.content,
|
156
|
+
"timestamp": self.timestamp,
|
157
|
+
"metadata": self.metadata,
|
158
|
+
}
|
159
|
+
|
160
|
+
|
161
|
+
@dataclass
|
162
|
+
class ToolAnnotation:
|
163
|
+
"""Tool annotation for metadata."""
|
164
|
+
|
165
|
+
is_read_only: bool = False
|
166
|
+
is_destructive: bool = False
|
167
|
+
is_idempotent: bool = True
|
168
|
+
estimated_duration: Optional[float] = None
|
169
|
+
requires_confirmation: bool = False
|
170
|
+
security_level: str = "normal" # normal, elevated, admin
|
171
|
+
rate_limit: Optional[Dict[str, Any]] = None
|
172
|
+
|
173
|
+
def to_dict(self) -> Dict[str, Any]:
|
174
|
+
"""Convert to dictionary format."""
|
175
|
+
return asdict(self)
|
176
|
+
|
177
|
+
|
178
|
+
class MultiModalContent:
|
179
|
+
"""Multi-modal content container."""
|
180
|
+
|
181
|
+
def __init__(self):
|
182
|
+
"""Initialize multi-modal content."""
|
183
|
+
self.content_items: List[Content] = []
|
184
|
+
|
185
|
+
def add_text(self, text: str, annotations: Optional[Dict[str, Any]] = None) -> None:
|
186
|
+
"""Add text content.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
text: Text content
|
190
|
+
annotations: Optional annotations
|
191
|
+
"""
|
192
|
+
self.content_items.append(
|
193
|
+
Content(type=ContentType.TEXT, data=text, annotations=annotations or {})
|
194
|
+
)
|
195
|
+
|
196
|
+
def add_image(
|
197
|
+
self,
|
198
|
+
image_data: Union[str, bytes],
|
199
|
+
mime_type: str,
|
200
|
+
annotations: Optional[Dict[str, Any]] = None,
|
201
|
+
) -> None:
|
202
|
+
"""Add image content.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
image_data: Image data (base64 string or bytes)
|
206
|
+
mime_type: Image MIME type
|
207
|
+
annotations: Optional annotations
|
208
|
+
"""
|
209
|
+
if isinstance(image_data, bytes):
|
210
|
+
image_data = base64.b64encode(image_data).decode()
|
211
|
+
|
212
|
+
self.content_items.append(
|
213
|
+
Content(
|
214
|
+
type=ContentType.IMAGE,
|
215
|
+
data=image_data,
|
216
|
+
mime_type=mime_type,
|
217
|
+
annotations=annotations or {},
|
218
|
+
)
|
219
|
+
)
|
220
|
+
|
221
|
+
def add_audio(
|
222
|
+
self,
|
223
|
+
audio_data: Union[str, bytes],
|
224
|
+
mime_type: str,
|
225
|
+
annotations: Optional[Dict[str, Any]] = None,
|
226
|
+
) -> None:
|
227
|
+
"""Add audio content.
|
228
|
+
|
229
|
+
Args:
|
230
|
+
audio_data: Audio data (base64 string or bytes)
|
231
|
+
mime_type: Audio MIME type
|
232
|
+
annotations: Optional annotations
|
233
|
+
"""
|
234
|
+
if isinstance(audio_data, bytes):
|
235
|
+
audio_data = base64.b64encode(audio_data).decode()
|
236
|
+
|
237
|
+
self.content_items.append(
|
238
|
+
Content(
|
239
|
+
type=ContentType.AUDIO,
|
240
|
+
data=audio_data,
|
241
|
+
mime_type=mime_type,
|
242
|
+
annotations=annotations or {},
|
243
|
+
)
|
244
|
+
)
|
245
|
+
|
246
|
+
def add_resource(
|
247
|
+
self,
|
248
|
+
uri: str,
|
249
|
+
text: Optional[str] = None,
|
250
|
+
mime_type: Optional[str] = None,
|
251
|
+
annotations: Optional[Dict[str, Any]] = None,
|
252
|
+
) -> None:
|
253
|
+
"""Add resource reference.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
uri: Resource URI
|
257
|
+
text: Optional text content
|
258
|
+
mime_type: Optional MIME type
|
259
|
+
annotations: Optional annotations
|
260
|
+
"""
|
261
|
+
resource_data = {"uri": uri}
|
262
|
+
if text:
|
263
|
+
resource_data["text"] = text
|
264
|
+
if mime_type:
|
265
|
+
resource_data["mimeType"] = mime_type
|
266
|
+
|
267
|
+
self.content_items.append(
|
268
|
+
Content(
|
269
|
+
type=ContentType.RESOURCE,
|
270
|
+
data=resource_data,
|
271
|
+
annotations=annotations or {},
|
272
|
+
)
|
273
|
+
)
|
274
|
+
|
275
|
+
def add_annotation(
|
276
|
+
self,
|
277
|
+
annotation_type: str,
|
278
|
+
data: Dict[str, Any],
|
279
|
+
annotations: Optional[Dict[str, Any]] = None,
|
280
|
+
) -> None:
|
281
|
+
"""Add annotation content.
|
282
|
+
|
283
|
+
Args:
|
284
|
+
annotation_type: Type of annotation
|
285
|
+
data: Annotation data
|
286
|
+
annotations: Optional meta-annotations
|
287
|
+
"""
|
288
|
+
annotation_data = {"type": annotation_type, "data": data}
|
289
|
+
|
290
|
+
self.content_items.append(
|
291
|
+
Content(
|
292
|
+
type=ContentType.ANNOTATION,
|
293
|
+
data=annotation_data,
|
294
|
+
annotations=annotations or {},
|
295
|
+
)
|
296
|
+
)
|
297
|
+
|
298
|
+
def to_list(self) -> List[Dict[str, Any]]:
|
299
|
+
"""Convert to list format for MCP protocol.
|
300
|
+
|
301
|
+
Returns:
|
302
|
+
List of content dictionaries
|
303
|
+
"""
|
304
|
+
return [item.to_dict() for item in self.content_items]
|
305
|
+
|
306
|
+
def is_empty(self) -> bool:
|
307
|
+
"""Check if content is empty."""
|
308
|
+
return len(self.content_items) == 0
|
309
|
+
|
310
|
+
|
311
|
+
class SchemaValidator:
|
312
|
+
"""JSON Schema validator for tool outputs and inputs."""
|
313
|
+
|
314
|
+
def __init__(self, schema: Dict[str, Any]):
|
315
|
+
"""Initialize schema validator.
|
316
|
+
|
317
|
+
Args:
|
318
|
+
schema: JSON Schema definition
|
319
|
+
"""
|
320
|
+
self.schema = schema
|
321
|
+
self._validator = jsonschema.Draft7Validator(schema)
|
322
|
+
|
323
|
+
def validate(self, data: Any) -> None:
|
324
|
+
"""Validate data against schema.
|
325
|
+
|
326
|
+
Args:
|
327
|
+
data: Data to validate
|
328
|
+
|
329
|
+
Raises:
|
330
|
+
ValidationError: If validation fails
|
331
|
+
"""
|
332
|
+
errors = list(self._validator.iter_errors(data))
|
333
|
+
if errors:
|
334
|
+
error_messages = [
|
335
|
+
f"{'.'.join(str(p) for p in error.absolute_path)}: {error.message}"
|
336
|
+
for error in errors
|
337
|
+
]
|
338
|
+
raise ValidationError(
|
339
|
+
f"Schema validation failed: {'; '.join(error_messages)}"
|
340
|
+
)
|
341
|
+
|
342
|
+
def is_valid(self, data: Any) -> bool:
|
343
|
+
"""Check if data is valid.
|
344
|
+
|
345
|
+
Args:
|
346
|
+
data: Data to check
|
347
|
+
|
348
|
+
Returns:
|
349
|
+
True if valid
|
350
|
+
"""
|
351
|
+
try:
|
352
|
+
self.validate(data)
|
353
|
+
return True
|
354
|
+
except ValidationError:
|
355
|
+
return False
|
356
|
+
|
357
|
+
|
358
|
+
class StructuredTool:
|
359
|
+
"""Tool with structured input/output validation."""
|
360
|
+
|
361
|
+
def __init__(
|
362
|
+
self,
|
363
|
+
input_schema: Optional[Dict[str, Any]] = None,
|
364
|
+
output_schema: Optional[Dict[str, Any]] = None,
|
365
|
+
annotations: Optional[ToolAnnotation] = None,
|
366
|
+
progress_reporting: bool = False,
|
367
|
+
):
|
368
|
+
"""Initialize structured tool.
|
369
|
+
|
370
|
+
Args:
|
371
|
+
input_schema: JSON Schema for input validation
|
372
|
+
output_schema: JSON Schema for output validation
|
373
|
+
annotations: Tool annotations
|
374
|
+
progress_reporting: Enable progress reporting
|
375
|
+
"""
|
376
|
+
self.input_schema = input_schema
|
377
|
+
self.output_schema = output_schema
|
378
|
+
self.annotations = annotations or ToolAnnotation()
|
379
|
+
self.progress_reporting = progress_reporting
|
380
|
+
|
381
|
+
# Create validators
|
382
|
+
self.input_validator = SchemaValidator(input_schema) if input_schema else None
|
383
|
+
self.output_validator = (
|
384
|
+
SchemaValidator(output_schema) if output_schema else None
|
385
|
+
)
|
386
|
+
|
387
|
+
def __call__(self, func: Callable) -> Callable:
|
388
|
+
"""Decorator to wrap function with validation."""
|
389
|
+
|
390
|
+
async def async_wrapper(*args, **kwargs):
|
391
|
+
# Input validation
|
392
|
+
if self.input_validator and kwargs:
|
393
|
+
try:
|
394
|
+
self.input_validator.validate(kwargs)
|
395
|
+
except ValidationError as e:
|
396
|
+
raise MCPError(
|
397
|
+
f"Input validation failed: {e}",
|
398
|
+
error_code=MCPErrorCode.INVALID_PARAMS,
|
399
|
+
)
|
400
|
+
|
401
|
+
# Progress reporting setup
|
402
|
+
progress_token = None
|
403
|
+
if self.progress_reporting:
|
404
|
+
protocol = get_protocol_manager()
|
405
|
+
progress_token = protocol.progress.start_progress(func.__name__)
|
406
|
+
kwargs["progress_token"] = progress_token
|
407
|
+
|
408
|
+
try:
|
409
|
+
# Execute function
|
410
|
+
result = await func(*args, **kwargs)
|
411
|
+
|
412
|
+
# Output validation
|
413
|
+
if self.output_validator:
|
414
|
+
try:
|
415
|
+
self.output_validator.validate(result)
|
416
|
+
except ValidationError as e:
|
417
|
+
raise MCPError(
|
418
|
+
f"Output validation failed: {e}",
|
419
|
+
error_code=MCPErrorCode.INTERNAL_ERROR,
|
420
|
+
)
|
421
|
+
|
422
|
+
# Complete progress
|
423
|
+
if progress_token:
|
424
|
+
await protocol.progress.complete_progress(
|
425
|
+
progress_token, "completed"
|
426
|
+
)
|
427
|
+
|
428
|
+
return result
|
429
|
+
|
430
|
+
except Exception as e:
|
431
|
+
# Error handling
|
432
|
+
if progress_token:
|
433
|
+
await protocol.progress.complete_progress(
|
434
|
+
progress_token, f"failed: {str(e)}"
|
435
|
+
)
|
436
|
+
raise
|
437
|
+
|
438
|
+
def sync_wrapper(*args, **kwargs):
|
439
|
+
# Input validation
|
440
|
+
if self.input_validator and kwargs:
|
441
|
+
try:
|
442
|
+
self.input_validator.validate(kwargs)
|
443
|
+
except ValidationError as e:
|
444
|
+
raise MCPError(
|
445
|
+
f"Input validation failed: {e}",
|
446
|
+
error_code=MCPErrorCode.INVALID_PARAMS,
|
447
|
+
)
|
448
|
+
|
449
|
+
# Execute function
|
450
|
+
result = func(*args, **kwargs)
|
451
|
+
|
452
|
+
# Output validation
|
453
|
+
if self.output_validator:
|
454
|
+
try:
|
455
|
+
self.output_validator.validate(result)
|
456
|
+
except ValidationError as e:
|
457
|
+
raise MCPError(
|
458
|
+
f"Output validation failed: {e}",
|
459
|
+
error_code=MCPErrorCode.INTERNAL_ERROR,
|
460
|
+
)
|
461
|
+
|
462
|
+
return result
|
463
|
+
|
464
|
+
# Return appropriate wrapper
|
465
|
+
if asyncio.iscoroutinefunction(func):
|
466
|
+
return async_wrapper
|
467
|
+
else:
|
468
|
+
return sync_wrapper
|
469
|
+
|
470
|
+
|
471
|
+
class ResourceTemplate:
|
472
|
+
"""Resource template with URI patterns and subscriptions."""
|
473
|
+
|
474
|
+
def __init__(
|
475
|
+
self,
|
476
|
+
uri_template: str,
|
477
|
+
name: Optional[str] = None,
|
478
|
+
description: Optional[str] = None,
|
479
|
+
mime_type: Optional[str] = None,
|
480
|
+
supports_subscription: bool = True,
|
481
|
+
):
|
482
|
+
"""Initialize resource template.
|
483
|
+
|
484
|
+
Args:
|
485
|
+
uri_template: URI template pattern
|
486
|
+
name: Template name
|
487
|
+
description: Template description
|
488
|
+
mime_type: Default MIME type
|
489
|
+
supports_subscription: Support change subscriptions
|
490
|
+
"""
|
491
|
+
self.uri_template = uri_template
|
492
|
+
self.name = name
|
493
|
+
self.description = description
|
494
|
+
self.mime_type = mime_type
|
495
|
+
self.supports_subscription = supports_subscription
|
496
|
+
|
497
|
+
# Subscription management
|
498
|
+
self._subscriptions: Dict[str, List[Callable]] = {}
|
499
|
+
self._subscription_ids: Dict[str, str] = {}
|
500
|
+
|
501
|
+
def matches_uri(self, uri: str) -> bool:
|
502
|
+
"""Check if URI matches this template.
|
503
|
+
|
504
|
+
Args:
|
505
|
+
uri: URI to check
|
506
|
+
|
507
|
+
Returns:
|
508
|
+
True if matches template
|
509
|
+
"""
|
510
|
+
# Simple pattern matching - check scheme first
|
511
|
+
if "://" in self.uri_template and "://" in uri:
|
512
|
+
template_scheme = self.uri_template.split("://")[0]
|
513
|
+
uri_scheme = uri.split("://")[0]
|
514
|
+
|
515
|
+
if template_scheme != uri_scheme:
|
516
|
+
return False
|
517
|
+
|
518
|
+
# For basic matching, just check if URI starts with the template prefix
|
519
|
+
# (without the variable parts)
|
520
|
+
template_prefix = self.uri_template.split("{")[0]
|
521
|
+
return uri.startswith(template_prefix)
|
522
|
+
|
523
|
+
async def subscribe(
|
524
|
+
self, uri: str, callback: Callable[[ResourceChange], None]
|
525
|
+
) -> str:
|
526
|
+
"""Subscribe to resource changes.
|
527
|
+
|
528
|
+
Args:
|
529
|
+
uri: Resource URI to monitor
|
530
|
+
callback: Change notification callback
|
531
|
+
|
532
|
+
Returns:
|
533
|
+
Subscription ID
|
534
|
+
"""
|
535
|
+
if not self.supports_subscription:
|
536
|
+
raise MCPError(
|
537
|
+
"Resource does not support subscriptions",
|
538
|
+
error_code=MCPErrorCode.METHOD_NOT_FOUND,
|
539
|
+
)
|
540
|
+
|
541
|
+
if not self.matches_uri(uri):
|
542
|
+
raise MCPError(
|
543
|
+
"URI does not match template", error_code=MCPErrorCode.INVALID_PARAMS
|
544
|
+
)
|
545
|
+
|
546
|
+
subscription_id = str(uuid.uuid4())
|
547
|
+
|
548
|
+
if uri not in self._subscriptions:
|
549
|
+
self._subscriptions[uri] = []
|
550
|
+
|
551
|
+
self._subscriptions[uri].append(callback)
|
552
|
+
self._subscription_ids[subscription_id] = uri
|
553
|
+
|
554
|
+
logger.info(f"Created subscription {subscription_id} for {uri}")
|
555
|
+
return subscription_id
|
556
|
+
|
557
|
+
async def unsubscribe(self, subscription_id: str) -> bool:
|
558
|
+
"""Unsubscribe from resource changes.
|
559
|
+
|
560
|
+
Args:
|
561
|
+
subscription_id: Subscription ID to remove
|
562
|
+
|
563
|
+
Returns:
|
564
|
+
True if subscription was removed
|
565
|
+
"""
|
566
|
+
if subscription_id not in self._subscription_ids:
|
567
|
+
return False
|
568
|
+
|
569
|
+
uri = self._subscription_ids[subscription_id]
|
570
|
+
|
571
|
+
# Find and remove callback (simplified - in production, track callback references)
|
572
|
+
if uri in self._subscriptions and self._subscriptions[uri]:
|
573
|
+
self._subscriptions[uri].pop() # Remove last callback (simplified)
|
574
|
+
|
575
|
+
if not self._subscriptions[uri]:
|
576
|
+
del self._subscriptions[uri]
|
577
|
+
|
578
|
+
del self._subscription_ids[subscription_id]
|
579
|
+
|
580
|
+
logger.info(f"Removed subscription {subscription_id} for {uri}")
|
581
|
+
return True
|
582
|
+
|
583
|
+
async def notify_change(self, change: ResourceChange) -> None:
|
584
|
+
"""Notify subscribers of resource change.
|
585
|
+
|
586
|
+
Args:
|
587
|
+
change: Resource change details
|
588
|
+
"""
|
589
|
+
uri = change.uri
|
590
|
+
if uri not in self._subscriptions:
|
591
|
+
return
|
592
|
+
|
593
|
+
# Notify all subscribers
|
594
|
+
for callback in self._subscriptions[uri]:
|
595
|
+
try:
|
596
|
+
if asyncio.iscoroutinefunction(callback):
|
597
|
+
await callback(change)
|
598
|
+
else:
|
599
|
+
callback(change)
|
600
|
+
except Exception as e:
|
601
|
+
logger.error(f"Subscription callback error: {e}")
|
602
|
+
|
603
|
+
def to_dict(self) -> Dict[str, Any]:
|
604
|
+
"""Convert to dictionary format."""
|
605
|
+
result = {"uriTemplate": self.uri_template}
|
606
|
+
|
607
|
+
if self.name:
|
608
|
+
result["name"] = self.name
|
609
|
+
if self.description:
|
610
|
+
result["description"] = self.description
|
611
|
+
if self.mime_type:
|
612
|
+
result["mimeType"] = self.mime_type
|
613
|
+
|
614
|
+
return result
|
615
|
+
|
616
|
+
|
617
|
+
class BinaryResourceHandler:
|
618
|
+
"""Handler for binary resources with Base64 encoding."""
|
619
|
+
|
620
|
+
def __init__(self, max_size: int = 10_000_000): # 10MB default
|
621
|
+
"""Initialize binary resource handler.
|
622
|
+
|
623
|
+
Args:
|
624
|
+
max_size: Maximum file size in bytes
|
625
|
+
"""
|
626
|
+
self.max_size = max_size
|
627
|
+
|
628
|
+
async def read_binary_file(self, file_path: Union[str, Path]) -> Dict[str, Any]:
|
629
|
+
"""Read binary file and encode as Base64.
|
630
|
+
|
631
|
+
Args:
|
632
|
+
file_path: Path to binary file
|
633
|
+
|
634
|
+
Returns:
|
635
|
+
Resource content with Base64 data
|
636
|
+
"""
|
637
|
+
file_path = Path(file_path)
|
638
|
+
|
639
|
+
if not file_path.exists():
|
640
|
+
raise MCPError(
|
641
|
+
f"File not found: {file_path}",
|
642
|
+
error_code=MCPErrorCode.RESOURCE_NOT_FOUND,
|
643
|
+
)
|
644
|
+
|
645
|
+
file_size = file_path.stat().st_size
|
646
|
+
if file_size > self.max_size:
|
647
|
+
raise MCPError(
|
648
|
+
f"File too large: {file_size} bytes",
|
649
|
+
error_code=MCPErrorCode.INVALID_PARAMS,
|
650
|
+
)
|
651
|
+
|
652
|
+
# Read file and encode
|
653
|
+
with open(file_path, "rb") as f:
|
654
|
+
binary_data = f.read()
|
655
|
+
|
656
|
+
encoded_data = base64.b64encode(binary_data).decode()
|
657
|
+
|
658
|
+
# Detect MIME type
|
659
|
+
mime_type, _ = mimetypes.guess_type(str(file_path))
|
660
|
+
if not mime_type:
|
661
|
+
mime_type = "application/octet-stream"
|
662
|
+
|
663
|
+
return {
|
664
|
+
"uri": f"file://{file_path.absolute()}",
|
665
|
+
"mimeType": mime_type,
|
666
|
+
"blob": encoded_data,
|
667
|
+
}
|
668
|
+
|
669
|
+
def decode_base64_content(self, encoded_data: str) -> bytes:
|
670
|
+
"""Decode Base64 content to bytes.
|
671
|
+
|
672
|
+
Args:
|
673
|
+
encoded_data: Base64 encoded data
|
674
|
+
|
675
|
+
Returns:
|
676
|
+
Binary data
|
677
|
+
"""
|
678
|
+
try:
|
679
|
+
return base64.b64decode(encoded_data)
|
680
|
+
except Exception as e:
|
681
|
+
raise MCPError(
|
682
|
+
f"Invalid Base64 data: {e}", error_code=MCPErrorCode.INVALID_PARAMS
|
683
|
+
)
|
684
|
+
|
685
|
+
|
686
|
+
class StreamingHandler:
|
687
|
+
"""Handler for streaming large responses."""
|
688
|
+
|
689
|
+
def __init__(self, chunk_size: int = 8192):
|
690
|
+
"""Initialize streaming handler.
|
691
|
+
|
692
|
+
Args:
|
693
|
+
chunk_size: Size of each chunk in bytes
|
694
|
+
"""
|
695
|
+
self.chunk_size = chunk_size
|
696
|
+
|
697
|
+
async def stream_text(self, text: str) -> AsyncGenerator[str, None]:
|
698
|
+
"""Stream text content in chunks.
|
699
|
+
|
700
|
+
Args:
|
701
|
+
text: Text to stream
|
702
|
+
|
703
|
+
Yields:
|
704
|
+
Text chunks
|
705
|
+
"""
|
706
|
+
for i in range(0, len(text), self.chunk_size):
|
707
|
+
chunk = text[i : i + self.chunk_size]
|
708
|
+
yield chunk
|
709
|
+
await asyncio.sleep(0) # Allow other tasks to run
|
710
|
+
|
711
|
+
async def stream_binary(self, data: bytes) -> AsyncGenerator[str, None]:
|
712
|
+
"""Stream binary content as Base64 chunks.
|
713
|
+
|
714
|
+
Args:
|
715
|
+
data: Binary data to stream
|
716
|
+
|
717
|
+
Yields:
|
718
|
+
Base64 encoded chunks
|
719
|
+
"""
|
720
|
+
for i in range(0, len(data), self.chunk_size):
|
721
|
+
chunk = data[i : i + self.chunk_size]
|
722
|
+
encoded_chunk = base64.b64encode(chunk).decode()
|
723
|
+
yield encoded_chunk
|
724
|
+
await asyncio.sleep(0) # Allow other tasks to run
|
725
|
+
|
726
|
+
async def stream_file(
|
727
|
+
self, file_path: Union[str, Path]
|
728
|
+
) -> AsyncGenerator[str, None]:
|
729
|
+
"""Stream file content as Base64 chunks.
|
730
|
+
|
731
|
+
Args:
|
732
|
+
file_path: Path to file
|
733
|
+
|
734
|
+
Yields:
|
735
|
+
Base64 encoded chunks
|
736
|
+
"""
|
737
|
+
file_path = Path(file_path)
|
738
|
+
|
739
|
+
if not file_path.exists():
|
740
|
+
raise MCPError(
|
741
|
+
f"File not found: {file_path}",
|
742
|
+
error_code=MCPErrorCode.RESOURCE_NOT_FOUND,
|
743
|
+
)
|
744
|
+
|
745
|
+
with open(file_path, "rb") as f:
|
746
|
+
while True:
|
747
|
+
chunk = f.read(self.chunk_size)
|
748
|
+
if not chunk:
|
749
|
+
break
|
750
|
+
|
751
|
+
encoded_chunk = base64.b64encode(chunk).decode()
|
752
|
+
yield encoded_chunk
|
753
|
+
await asyncio.sleep(0) # Allow other tasks to run
|
754
|
+
|
755
|
+
|
756
|
+
class ElicitationSystem:
|
757
|
+
"""Interactive user input collection system."""
|
758
|
+
|
759
|
+
def __init__(self):
|
760
|
+
"""Initialize elicitation system."""
|
761
|
+
self._pending_requests: Dict[str, Dict[str, Any]] = {}
|
762
|
+
self._response_callbacks: Dict[str, Callable] = {}
|
763
|
+
|
764
|
+
async def request_input(
|
765
|
+
self,
|
766
|
+
prompt: str,
|
767
|
+
input_schema: Optional[Dict[str, Any]] = None,
|
768
|
+
timeout: Optional[float] = 300.0,
|
769
|
+
) -> Any:
|
770
|
+
"""Request input from user with schema validation.
|
771
|
+
|
772
|
+
Args:
|
773
|
+
prompt: Input prompt for user
|
774
|
+
input_schema: JSON Schema for input validation
|
775
|
+
timeout: Request timeout in seconds
|
776
|
+
|
777
|
+
Returns:
|
778
|
+
User input
|
779
|
+
"""
|
780
|
+
request_id = str(uuid.uuid4())
|
781
|
+
|
782
|
+
# Store request
|
783
|
+
self._pending_requests[request_id] = {
|
784
|
+
"prompt": prompt,
|
785
|
+
"schema": input_schema,
|
786
|
+
"timestamp": time.time(),
|
787
|
+
}
|
788
|
+
|
789
|
+
# Create future for response
|
790
|
+
response_future = asyncio.Future()
|
791
|
+
self._response_callbacks[request_id] = lambda data: response_future.set_result(
|
792
|
+
data
|
793
|
+
)
|
794
|
+
|
795
|
+
try:
|
796
|
+
# Send elicitation request (would be sent to client in real implementation)
|
797
|
+
await self._send_elicitation_request(request_id, prompt, input_schema)
|
798
|
+
|
799
|
+
# Wait for response
|
800
|
+
if timeout:
|
801
|
+
response = await asyncio.wait_for(response_future, timeout=timeout)
|
802
|
+
else:
|
803
|
+
response = await response_future
|
804
|
+
|
805
|
+
# Validate response
|
806
|
+
if input_schema:
|
807
|
+
validator = SchemaValidator(input_schema)
|
808
|
+
validator.validate(response)
|
809
|
+
|
810
|
+
return response
|
811
|
+
|
812
|
+
except asyncio.TimeoutError:
|
813
|
+
raise MCPError(
|
814
|
+
"Input request timed out", error_code=MCPErrorCode.REQUEST_TIMEOUT
|
815
|
+
)
|
816
|
+
finally:
|
817
|
+
# Clean up
|
818
|
+
self._pending_requests.pop(request_id, None)
|
819
|
+
self._response_callbacks.pop(request_id, None)
|
820
|
+
|
821
|
+
async def provide_input(self, request_id: str, input_data: Any) -> bool:
|
822
|
+
"""Provide input for pending request.
|
823
|
+
|
824
|
+
Args:
|
825
|
+
request_id: Request ID
|
826
|
+
input_data: User input data
|
827
|
+
|
828
|
+
Returns:
|
829
|
+
True if input was accepted
|
830
|
+
"""
|
831
|
+
if request_id not in self._pending_requests:
|
832
|
+
return False
|
833
|
+
|
834
|
+
callback = self._response_callbacks.get(request_id)
|
835
|
+
if callback:
|
836
|
+
callback(input_data)
|
837
|
+
return True
|
838
|
+
|
839
|
+
return False
|
840
|
+
|
841
|
+
async def _send_elicitation_request(
|
842
|
+
self, request_id: str, prompt: str, schema: Optional[Dict[str, Any]]
|
843
|
+
) -> None:
|
844
|
+
"""Send elicitation request to client.
|
845
|
+
|
846
|
+
Args:
|
847
|
+
request_id: Request ID
|
848
|
+
prompt: Input prompt
|
849
|
+
schema: Input schema
|
850
|
+
"""
|
851
|
+
# In a real implementation, this would send the request to the MCP client
|
852
|
+
# For now, we'll just log it
|
853
|
+
logger.info(f"Elicitation request {request_id}: {prompt}")
|
854
|
+
|
855
|
+
# Simulate automatic response for testing
|
856
|
+
if prompt.lower().startswith("test"):
|
857
|
+
await asyncio.sleep(0.1) # Simulate user thinking time
|
858
|
+
await self.provide_input(request_id, "test response")
|
859
|
+
|
860
|
+
|
861
|
+
class ProgressReporter:
|
862
|
+
"""Enhanced progress reporting for long-running operations."""
|
863
|
+
|
864
|
+
def __init__(self, operation_name: str, total: Optional[float] = None):
|
865
|
+
"""Initialize progress reporter.
|
866
|
+
|
867
|
+
Args:
|
868
|
+
operation_name: Name of the operation
|
869
|
+
total: Total progress units (if known)
|
870
|
+
"""
|
871
|
+
self.operation_name = operation_name
|
872
|
+
self.total = total
|
873
|
+
self.current = 0.0
|
874
|
+
self.status = "started"
|
875
|
+
|
876
|
+
# Get progress token from protocol manager
|
877
|
+
protocol = get_protocol_manager()
|
878
|
+
self.progress_token = protocol.progress.start_progress(operation_name, total)
|
879
|
+
|
880
|
+
async def update(
|
881
|
+
self,
|
882
|
+
progress: Optional[float] = None,
|
883
|
+
status: Optional[str] = None,
|
884
|
+
increment: Optional[float] = None,
|
885
|
+
) -> None:
|
886
|
+
"""Update progress.
|
887
|
+
|
888
|
+
Args:
|
889
|
+
progress: Current progress value
|
890
|
+
status: Status message
|
891
|
+
increment: Amount to increment progress
|
892
|
+
"""
|
893
|
+
protocol = get_protocol_manager()
|
894
|
+
|
895
|
+
if progress is not None:
|
896
|
+
self.current = progress
|
897
|
+
elif increment is not None:
|
898
|
+
self.current += increment
|
899
|
+
|
900
|
+
if status is not None:
|
901
|
+
self.status = status
|
902
|
+
|
903
|
+
await protocol.progress.update_progress(
|
904
|
+
self.progress_token,
|
905
|
+
progress=self.current,
|
906
|
+
status=self.status,
|
907
|
+
increment=increment,
|
908
|
+
)
|
909
|
+
|
910
|
+
async def complete(self, status: str = "completed") -> None:
|
911
|
+
"""Complete progress reporting.
|
912
|
+
|
913
|
+
Args:
|
914
|
+
status: Final status
|
915
|
+
"""
|
916
|
+
protocol = get_protocol_manager()
|
917
|
+
await protocol.progress.complete_progress(self.progress_token, status)
|
918
|
+
|
919
|
+
async def __aenter__(self):
|
920
|
+
"""Async context manager entry."""
|
921
|
+
return self
|
922
|
+
|
923
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
924
|
+
"""Async context manager exit."""
|
925
|
+
if exc_type:
|
926
|
+
await self.complete(f"failed: {exc_val}")
|
927
|
+
else:
|
928
|
+
await self.complete()
|
929
|
+
|
930
|
+
|
931
|
+
class CancellationContext:
|
932
|
+
"""Context for handling request cancellation."""
|
933
|
+
|
934
|
+
def __init__(self, request_id: str):
|
935
|
+
"""Initialize cancellation context.
|
936
|
+
|
937
|
+
Args:
|
938
|
+
request_id: Request ID to monitor for cancellation
|
939
|
+
"""
|
940
|
+
self.request_id = request_id
|
941
|
+
self._cleanup_functions: List[Callable] = []
|
942
|
+
|
943
|
+
def is_cancelled(self) -> bool:
|
944
|
+
"""Check if request is cancelled."""
|
945
|
+
protocol = get_protocol_manager()
|
946
|
+
return protocol.cancellation.is_cancelled(self.request_id)
|
947
|
+
|
948
|
+
def check_cancellation(self) -> None:
|
949
|
+
"""Check for cancellation and raise exception if cancelled."""
|
950
|
+
if self.is_cancelled():
|
951
|
+
raise MCPError(
|
952
|
+
"Operation was cancelled", error_code=MCPErrorCode.REQUEST_CANCELLED
|
953
|
+
)
|
954
|
+
|
955
|
+
def add_cleanup(self, cleanup_func: Callable) -> None:
|
956
|
+
"""Add cleanup function.
|
957
|
+
|
958
|
+
Args:
|
959
|
+
cleanup_func: Function to call on cancellation
|
960
|
+
"""
|
961
|
+
self._cleanup_functions.append(cleanup_func)
|
962
|
+
|
963
|
+
# Register with protocol manager
|
964
|
+
protocol = get_protocol_manager()
|
965
|
+
protocol.cancellation.add_cleanup_function(self.request_id, cleanup_func)
|
966
|
+
|
967
|
+
async def __aenter__(self):
|
968
|
+
"""Async context manager entry."""
|
969
|
+
return self
|
970
|
+
|
971
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
972
|
+
"""Async context manager exit."""
|
973
|
+
# Cleanup is handled by protocol manager
|
974
|
+
pass
|
975
|
+
|
976
|
+
|
977
|
+
# Convenience functions
|
978
|
+
def structured_tool(
|
979
|
+
input_schema: Optional[Dict[str, Any]] = None,
|
980
|
+
output_schema: Optional[Dict[str, Any]] = None,
|
981
|
+
annotations: Optional[ToolAnnotation] = None,
|
982
|
+
progress_reporting: bool = False,
|
983
|
+
):
|
984
|
+
"""Decorator for creating structured tools.
|
985
|
+
|
986
|
+
Args:
|
987
|
+
input_schema: JSON Schema for input validation
|
988
|
+
output_schema: JSON Schema for output validation
|
989
|
+
annotations: Tool annotations
|
990
|
+
progress_reporting: Enable progress reporting
|
991
|
+
|
992
|
+
Returns:
|
993
|
+
Tool decorator
|
994
|
+
"""
|
995
|
+
return StructuredTool(input_schema, output_schema, annotations, progress_reporting)
|
996
|
+
|
997
|
+
|
998
|
+
async def create_progress_reporter(
|
999
|
+
operation_name: str, total: Optional[float] = None
|
1000
|
+
) -> ProgressReporter:
|
1001
|
+
"""Create progress reporter.
|
1002
|
+
|
1003
|
+
Args:
|
1004
|
+
operation_name: Operation name
|
1005
|
+
total: Total progress units
|
1006
|
+
|
1007
|
+
Returns:
|
1008
|
+
Progress reporter
|
1009
|
+
"""
|
1010
|
+
return ProgressReporter(operation_name, total)
|
1011
|
+
|
1012
|
+
|
1013
|
+
def create_cancellation_context(request_id: str) -> CancellationContext:
|
1014
|
+
"""Create cancellation context.
|
1015
|
+
|
1016
|
+
Args:
|
1017
|
+
request_id: Request ID
|
1018
|
+
|
1019
|
+
Returns:
|
1020
|
+
Cancellation context
|
1021
|
+
"""
|
1022
|
+
return CancellationContext(request_id)
|