asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
asap/mcp/server.py ADDED
@@ -0,0 +1,333 @@
1
+ """MCP server implementation (spec 2025-11-25).
2
+
3
+ Runs over stdio: reads JSON-RPC messages (one per line) from stdin,
4
+ dispatches to handlers, writes responses to stdout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import io
11
+ import inspect
12
+ import json
13
+ import sys
14
+ from collections.abc import Callable
15
+ from typing import Any, cast
16
+
17
+ import jsonschema
18
+
19
+ from asap.mcp.protocol import (
20
+ INVALID_PARAMS,
21
+ INTERNAL_ERROR,
22
+ METHOD_NOT_FOUND,
23
+ MCP_PROTOCOL_VERSION,
24
+ PARSE_ERROR,
25
+ CallToolRequestParams,
26
+ CallToolResult,
27
+ Implementation,
28
+ InitializeResult,
29
+ JSONRPCError,
30
+ JSONRPCErrorResponse,
31
+ JSONRPCNotification,
32
+ JSONRPCRequest,
33
+ JSONRPCResponse,
34
+ ListToolsResult,
35
+ TextContent,
36
+ Tool,
37
+ )
38
+ from asap.observability import get_logger, is_debug_mode
39
+
40
+ logger = get_logger(__name__)
41
+
42
+ _INTERNAL_TOOL_ERROR_MESSAGE = "Internal tool error"
43
+
44
+ EMPTY_INPUT_SCHEMA: dict[str, Any] = {"type": "object", "additionalProperties": False}
45
+
46
+
47
+ class MCPServer:
48
+ """MCP server with stdio transport and tools support.
49
+
50
+ Register tools with register_tool(), then run with serve_stdio().
51
+ Supports initialize, tools/list, and tools/call per MCP 2025-11-25.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ name: str = "asap-mcp-server",
57
+ version: str = "1.0.0",
58
+ title: str | None = None,
59
+ description: str | None = None,
60
+ instructions: str | None = None,
61
+ ) -> None:
62
+ """Initialize the MCP server.
63
+
64
+ Args:
65
+ name: Server programmatic name.
66
+ version: Server version string.
67
+ title: Optional human-readable title.
68
+ description: Optional description.
69
+ instructions: Optional instructions for the client/LLM.
70
+ """
71
+ self._server_info = Implementation(
72
+ name=name,
73
+ version=version,
74
+ title=title or name,
75
+ description=description,
76
+ )
77
+ self._instructions = instructions
78
+ self._tools: dict[str, tuple[Callable[..., Any], dict[str, Any], str, str | None]] = {}
79
+ self._request_id_counter = 0
80
+
81
+ def register_tool(
82
+ self,
83
+ name: str,
84
+ func: Callable[..., Any],
85
+ schema: dict[str, Any],
86
+ *,
87
+ description: str = "",
88
+ title: str | None = None,
89
+ ) -> None:
90
+ """Register a tool that can be invoked via tools/call.
91
+
92
+ Args:
93
+ name: Unique tool name (e.g. "echo", "get_weather").
94
+ func: Callable that receives keyword arguments from the client.
95
+ May be sync or async; return value is converted to text for
96
+ the result content. The framework passes raw arguments as
97
+ keyword dict; each tool must validate its own inputs.
98
+ schema: JSON Schema for the tool's parameters (inputSchema).
99
+ Use EMPTY_INPUT_SCHEMA for no parameters.
100
+ description: Human-readable description (required by spec).
101
+ title: Optional display title.
102
+ """
103
+ input_schema = schema if schema else EMPTY_INPUT_SCHEMA
104
+ self._tools[name] = (
105
+ func,
106
+ input_schema,
107
+ description or f"Tool {name}",
108
+ title,
109
+ )
110
+
111
+ def _get_capabilities(self) -> dict[str, Any]:
112
+ """Server capabilities for initialize result."""
113
+ caps: dict[str, Any] = {}
114
+ if self._tools:
115
+ caps["tools"] = {"listChanged": True}
116
+ return caps
117
+
118
+ def _handle_initialize(self, params: dict[str, Any] | None) -> dict[str, Any]:
119
+ """Handle initialize request; return InitializeResult as dict."""
120
+ result = InitializeResult(
121
+ protocolVersion=MCP_PROTOCOL_VERSION,
122
+ capabilities=self._get_capabilities(),
123
+ serverInfo=self._server_info,
124
+ instructions=self._instructions,
125
+ )
126
+ return result.model_dump(by_alias=True, exclude_none=True)
127
+
128
+ def _handle_tools_list(self, params: dict[str, Any] | None) -> dict[str, Any]:
129
+ """Handle tools/list; return ListToolsResult as dict."""
130
+ tools_list: list[Tool] = []
131
+ for name, (_, input_schema, description, title) in self._tools.items():
132
+ tools_list.append(
133
+ Tool(
134
+ name=name,
135
+ description=description,
136
+ inputSchema=input_schema,
137
+ title=title,
138
+ )
139
+ )
140
+ result = ListToolsResult(tools=tools_list)
141
+ return result.model_dump(by_alias=True, exclude_none=True)
142
+
143
+ async def _handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
144
+ """Handle tools/call; execute tool and return CallToolResult as dict.
145
+
146
+ Raises:
147
+ ValueError: With code INVALID_PARAMS for malformed params or tool
148
+ argument mismatch (e.g. missing or invalid keyword arguments).
149
+ """
150
+ try:
151
+ parsed = CallToolRequestParams(**params)
152
+ except Exception as e:
153
+ raise ValueError(f"Invalid params: {e}") from e
154
+
155
+ if parsed.name not in self._tools:
156
+ return CallToolResult(
157
+ content=[
158
+ TextContent(text=f"Unknown tool: {parsed.name}").model_dump(by_alias=True)
159
+ ],
160
+ isError=True,
161
+ ).model_dump(by_alias=True, exclude_none=True)
162
+
163
+ func, input_schema, _desc, _title = self._tools[parsed.name]
164
+ try:
165
+ jsonschema.validate(instance=parsed.arguments, schema=input_schema)
166
+ except jsonschema.ValidationError as e:
167
+ raise ValueError(f"Invalid arguments: {e.message}") from e
168
+
169
+ try:
170
+ if inspect.iscoroutinefunction(func):
171
+ out = await func(**parsed.arguments)
172
+ else:
173
+ loop = asyncio.get_running_loop()
174
+ out = await loop.run_in_executor(None, lambda: func(**parsed.arguments))
175
+ except TypeError as e:
176
+ raise ValueError(f"Tool argument mismatch: {e}") from e
177
+ except Exception as e:
178
+ logger.exception("mcp.tool.error", tool=parsed.name, error=str(e))
179
+ message = str(e) if is_debug_mode() else _INTERNAL_TOOL_ERROR_MESSAGE
180
+ return CallToolResult(
181
+ content=[TextContent(text=message).model_dump(by_alias=True)],
182
+ isError=True,
183
+ ).model_dump(by_alias=True, exclude_none=True)
184
+
185
+ if isinstance(out, str):
186
+ text = out
187
+ elif isinstance(out, dict):
188
+ text = json.dumps(out)
189
+ else:
190
+ text = str(out)
191
+ return CallToolResult(
192
+ content=[TextContent(text=text).model_dump(by_alias=True)],
193
+ isError=False,
194
+ ).model_dump(by_alias=True, exclude_none=True)
195
+
196
+ async def _dispatch_request(self, req: JSONRPCRequest) -> str | None:
197
+ """Dispatch a JSON-RPC request; return response line or None for notification."""
198
+ method = req.method
199
+ params = req.params or {}
200
+ rid = req.id
201
+
202
+ if method == "initialize":
203
+ result = self._handle_initialize(params)
204
+ return json.dumps(JSONRPCResponse(id=rid, result=result).model_dump(by_alias=True))
205
+
206
+ if method == "tools/list":
207
+ result = self._handle_tools_list(params)
208
+ return json.dumps(JSONRPCResponse(id=rid, result=result).model_dump(by_alias=True))
209
+
210
+ if method == "tools/call":
211
+ try:
212
+ result = await self._handle_tools_call(params)
213
+ except ValueError as e:
214
+ return json.dumps(
215
+ JSONRPCErrorResponse(
216
+ id=rid,
217
+ error=JSONRPCError(code=INVALID_PARAMS, message=str(e)),
218
+ ).model_dump(by_alias=True)
219
+ )
220
+ return json.dumps(JSONRPCResponse(id=rid, result=result).model_dump(by_alias=True))
221
+
222
+ if method == "ping":
223
+ return json.dumps(JSONRPCResponse(id=rid, result={}).model_dump(by_alias=True))
224
+
225
+ return json.dumps(
226
+ JSONRPCErrorResponse(
227
+ id=rid,
228
+ error=JSONRPCError(code=METHOD_NOT_FOUND, message=f"Method not found: {method}"),
229
+ ).model_dump(by_alias=True)
230
+ )
231
+
232
+ def _dispatch_notification(self, notif: JSONRPCNotification) -> None:
233
+ """Handle notification (no response)."""
234
+ if notif.method == "notifications/initialized":
235
+ logger.debug("mcp.initialized", message="Client sent initialized")
236
+ elif notif.method == "notifications/cancelled":
237
+ logger.debug("mcp.cancelled", params=notif.params)
238
+ else:
239
+ logger.debug("mcp.notification", method=notif.method, params=notif.params)
240
+
241
+ async def serve_stdio(
242
+ self,
243
+ stdin: io.TextIOBase | None = None,
244
+ stdout: io.TextIOBase | None = None,
245
+ ) -> None:
246
+ """Run the server over stdio (stdin/stdout). Blocks until stdin closes.
247
+
248
+ Requests are processed sequentially: one request is handled at a time.
249
+ Long-running tool calls will block other requests (e.g. ping, cancel)
250
+ until they complete.
251
+
252
+ Args:
253
+ stdin: Optional input stream (default: sys.stdin).
254
+ stdout: Optional output stream (default: sys.stdout).
255
+ """
256
+ _stdin = stdin if stdin is not None else sys.stdin
257
+ _stdout = stdout if stdout is not None else sys.stdout
258
+ loop = asyncio.get_running_loop()
259
+ queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=128)
260
+
261
+ def read_stdin() -> dict[str, Any] | None:
262
+ try:
263
+ line = _stdin.readline()
264
+ except (EOFError, OSError) as e:
265
+ logger.debug("mcp.transport.closed", reason=str(e))
266
+ return None
267
+ if not line:
268
+ return None
269
+ line = line.rstrip("\n\r")
270
+ if not line:
271
+ return None
272
+ try:
273
+ data = json.loads(line)
274
+ if not isinstance(data, dict):
275
+ return {
276
+ "jsonrpc": "2.0",
277
+ "id": None,
278
+ "error": {"code": PARSE_ERROR, "message": "Parse error"},
279
+ }
280
+ return cast("dict[str, Any]", data)
281
+ except json.JSONDecodeError:
282
+ return {
283
+ "jsonrpc": "2.0",
284
+ "id": None,
285
+ "error": {"code": PARSE_ERROR, "message": "Parse error"},
286
+ }
287
+
288
+ async def producer() -> None:
289
+ while True:
290
+ raw = await loop.run_in_executor(None, read_stdin)
291
+ await queue.put(raw)
292
+ if raw is None:
293
+ break
294
+
295
+ async def consumer() -> None:
296
+ while True:
297
+ raw = await queue.get()
298
+ if raw is None:
299
+ break
300
+ if (
301
+ isinstance(raw, dict)
302
+ and "method" not in raw
303
+ and raw.get("error", {}).get("code") == PARSE_ERROR
304
+ ):
305
+ _stdout.write(json.dumps(raw) + "\n")
306
+ _stdout.flush()
307
+ continue
308
+ if "id" in raw and raw.get("method"):
309
+ try:
310
+ req = JSONRPCRequest(**raw)
311
+ response_line = await self._dispatch_request(req)
312
+ if response_line:
313
+ _stdout.write(response_line + "\n")
314
+ _stdout.flush()
315
+ except Exception as e:
316
+ logger.exception("mcp.request_error", error=str(e))
317
+ rid = raw.get("id")
318
+ err = JSONRPCErrorResponse(
319
+ id=rid,
320
+ error=JSONRPCError(code=INTERNAL_ERROR, message=str(e)),
321
+ )
322
+ _stdout.write(json.dumps(err.model_dump(by_alias=True)) + "\n")
323
+ _stdout.flush()
324
+ else:
325
+ try:
326
+ notif = JSONRPCNotification(**raw)
327
+ self._dispatch_notification(notif)
328
+ except Exception as e:
329
+ logger.debug("mcp.notification_error", error=str(e))
330
+
331
+ await asyncio.gather(producer(), consumer())
332
+
333
+ run_stdio = serve_stdio
@@ -0,0 +1,40 @@
1
+ """Entry point to run the MCP server over stdio.
2
+
3
+ Used by the demo and by clients that launch the server as a subprocess.
4
+
5
+ Example:
6
+ python -m asap.mcp.server_runner
7
+
8
+ Then send JSON-RPC messages (one per line) to stdin; read responses from stdout.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import sys
16
+
17
+
18
+ def main() -> None:
19
+ """Run MCP server with echo tool on stdio."""
20
+ from asap.mcp.server import MCPServer
21
+
22
+ server = MCPServer(
23
+ name="asap-mcp-demo",
24
+ version="1.0.0",
25
+ description="ASAP MCP demo server with echo tool",
26
+ )
27
+ server.register_tool(
28
+ "echo",
29
+ lambda message: message,
30
+ {"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"]},
31
+ description="Echo back the given message",
32
+ title="Echo",
33
+ )
34
+ with contextlib.suppress(BrokenPipeError, KeyboardInterrupt):
35
+ asyncio.run(server.run_stdio())
36
+ sys.exit(0)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
asap/models/__init__.py CHANGED
@@ -12,6 +12,8 @@ from asap.models.constants import (
12
12
  AGENT_URN_PATTERN,
13
13
  ASAP_PROTOCOL_VERSION,
14
14
  DEFAULT_TIMEOUT_SECONDS,
15
+ MAX_ENVELOPE_AGE_SECONDS,
16
+ MAX_FUTURE_TOLERANCE_SECONDS,
15
17
  MAX_TASK_DEPTH,
16
18
  )
17
19
 
@@ -88,6 +90,8 @@ __all__ = [
88
90
  "AGENT_URN_PATTERN",
89
91
  "ASAP_PROTOCOL_VERSION",
90
92
  "DEFAULT_TIMEOUT_SECONDS",
93
+ "MAX_ENVELOPE_AGE_SECONDS",
94
+ "MAX_FUTURE_TOLERANCE_SECONDS",
91
95
  "MAX_TASK_DEPTH",
92
96
  # Enums
93
97
  "MessageRole",
asap/models/base.py CHANGED
@@ -42,11 +42,8 @@ class ASAPBaseModel(BaseModel):
42
42
  # Allow populating fields by both name and alias
43
43
  populate_by_name=True,
44
44
  # JSON Schema configuration
45
- # Use enum values (not names) in schema
46
45
  use_enum_values=False,
47
- # Validate default values
48
46
  validate_default=True,
49
- # Validate assignments (only relevant if frozen=False)
50
47
  validate_assignment=True,
51
48
  # JSON Schema extras for better documentation
52
49
  json_schema_extra={
asap/models/constants.py CHANGED
@@ -11,5 +11,80 @@ DEFAULT_TIMEOUT_SECONDS = 600
11
11
  MAX_TASK_DEPTH = 10 # Maximum nesting level for subtasks
12
12
  MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB maximum request size
13
13
 
14
- # URN patterns
14
+ # Timestamp validation constants for replay attack prevention
15
+ MAX_ENVELOPE_AGE_SECONDS = 300 # 5 minutes
16
+ """Maximum age of an envelope timestamp before it is considered stale.
17
+
18
+ This prevents replay attacks by rejecting envelopes that are too old.
19
+ The 5-minute window balances security (preventing old message replays)
20
+ with practical network latency and clock skew tolerance.
21
+ """
22
+
23
+ MAX_FUTURE_TOLERANCE_SECONDS = 30 # 30 seconds
24
+ """Maximum future timestamp tolerance to account for clock skew.
25
+
26
+ Envelopes with timestamps more than 30 seconds in the future are rejected
27
+ to prevent attacks using artificially future-dated messages. This tolerance
28
+ accounts for reasonable clock synchronization differences between systems.
29
+ """
30
+
31
+ NONCE_TTL_SECONDS = MAX_ENVELOPE_AGE_SECONDS * 2 # 10 minutes by default
32
+ """Time-to-live for nonce values in seconds.
33
+
34
+ Nonces are stored with a TTL of 2x the maximum envelope age to ensure they
35
+ expire after the envelope would have been rejected anyway. This provides a
36
+ safety margin for edge cases where an envelope might be processed near the
37
+ age limit, while preventing the nonce store from growing unbounded.
38
+
39
+ The 2x multiplier ensures that:
40
+ - Nonces remain valid for the full envelope validation window
41
+ - Nonces expire shortly after envelopes would be rejected, preventing unbounded growth
42
+ - There's a buffer for clock skew and processing delays
43
+ """
44
+
45
+ # URN patterns and limits
15
46
  AGENT_URN_PATTERN = r"^urn:asap:agent:[a-z0-9-]+(?::[a-z0-9-]+)?$"
47
+ MAX_URN_LENGTH = 256
48
+ """Maximum length for agent URNs to prevent abuse and ensure consistent storage."""
49
+
50
+ # Authentication schemes
51
+ SUPPORTED_AUTH_SCHEMES = frozenset({"bearer", "basic"})
52
+ """Supported authentication schemes for agent access.
53
+
54
+ Currently supports:
55
+ - bearer: Bearer token authentication (RFC 6750)
56
+ - basic: HTTP Basic authentication (RFC 7617)
57
+
58
+ Future support planned:
59
+ - oauth2: OAuth 2.0 authentication flow
60
+ - hmac: HMAC-based authentication
61
+ """
62
+
63
+ # Retry and backoff constants
64
+ DEFAULT_BASE_DELAY = 1.0
65
+ """Default base delay in seconds for exponential backoff.
66
+
67
+ This is the initial delay before the first retry attempt. Subsequent retries
68
+ will use exponential backoff: base_delay * (2 ** attempt) + jitter.
69
+ """
70
+
71
+ DEFAULT_MAX_DELAY = 60.0
72
+ """Maximum delay in seconds for exponential backoff.
73
+
74
+ This caps the maximum delay between retry attempts, preventing excessively
75
+ long waits while still providing exponential backoff for transient failures.
76
+ """
77
+
78
+ DEFAULT_CIRCUIT_BREAKER_THRESHOLD = 5
79
+ """Default threshold for circuit breaker pattern.
80
+
81
+ Number of consecutive failures required before opening the circuit breaker
82
+ and preventing further requests to a failing endpoint.
83
+ """
84
+
85
+ DEFAULT_CIRCUIT_BREAKER_TIMEOUT = 60.0
86
+ """Default timeout in seconds before circuit breaker transitions from OPEN to HALF_OPEN.
87
+
88
+ After this timeout, the circuit breaker will allow a test request to determine
89
+ if the service has recovered before closing the circuit.
90
+ """
asap/models/entities.py CHANGED
@@ -14,15 +14,20 @@ This module defines the fundamental entities used in agent-to-agent communicatio
14
14
  - AuthScheme: Authentication configuration for agent access
15
15
  """
16
16
 
17
- import re
18
17
  from datetime import datetime
19
18
  from typing import Any
20
19
 
21
20
  from packaging.version import InvalidVersion, Version
22
- from pydantic import Field, field_validator
21
+ from pydantic import Field, field_validator, model_validator
23
22
 
23
+ from asap.errors import UnsupportedAuthSchemeError
24
24
  from asap.models.base import ASAPBaseModel
25
- from asap.models.constants import AGENT_URN_PATTERN, ASAP_PROTOCOL_VERSION
25
+ from asap.models.constants import (
26
+ ASAP_PROTOCOL_VERSION,
27
+ MAX_TASK_DEPTH,
28
+ SUPPORTED_AUTH_SCHEMES,
29
+ )
30
+ from asap.models.validators import validate_agent_urn
26
31
  from asap.models.enums import MessageRole, TaskStatus
27
32
  from asap.models.types import (
28
33
  AgentURN,
@@ -36,6 +41,27 @@ from asap.models.types import (
36
41
  )
37
42
 
38
43
 
44
+ def _validate_auth_scheme(auth: "AuthScheme") -> None:
45
+ """Validate that all authentication schemes are supported.
46
+
47
+ Checks each scheme in auth.schemes against SUPPORTED_AUTH_SCHEMES
48
+ and raises UnsupportedAuthSchemeError if any scheme is invalid.
49
+
50
+ Args:
51
+ auth: AuthScheme instance to validate
52
+
53
+ Raises:
54
+ UnsupportedAuthSchemeError: If any scheme is not supported
55
+ """
56
+ for scheme in auth.schemes:
57
+ scheme_lower = scheme.lower()
58
+ if scheme_lower not in SUPPORTED_AUTH_SCHEMES:
59
+ raise UnsupportedAuthSchemeError(
60
+ scheme=scheme,
61
+ supported_schemes=SUPPORTED_AUTH_SCHEMES,
62
+ )
63
+
64
+
39
65
  class Skill(ASAPBaseModel):
40
66
  """A specific capability that an agent can perform.
41
67
 
@@ -169,6 +195,12 @@ class Agent(ASAPBaseModel):
169
195
  manifest_uri: str = Field(..., description="URL to agent's manifest")
170
196
  capabilities: list[str] = Field(..., min_length=1, description="Agent capability strings")
171
197
 
198
+ @field_validator("id")
199
+ @classmethod
200
+ def validate_urn_format(cls, v: str) -> str:
201
+ """Validate agent ID URN format and length."""
202
+ return validate_agent_urn(v)
203
+
172
204
 
173
205
  class Manifest(ASAPBaseModel):
174
206
  """Self-describing metadata about an agent's capabilities.
@@ -216,10 +248,8 @@ class Manifest(ASAPBaseModel):
216
248
  @field_validator("id")
217
249
  @classmethod
218
250
  def validate_urn_format(cls, v: str) -> str:
219
- """Validate that agent ID follows URN format."""
220
- if not re.match(AGENT_URN_PATTERN, v):
221
- raise ValueError(f"Agent ID must follow URN format 'urn:asap:agent:{{name}}', got: {v}")
222
- return v
251
+ """Validate that agent ID follows URN format and length limits."""
252
+ return validate_agent_urn(v)
223
253
 
224
254
  @field_validator("version")
225
255
  @classmethod
@@ -231,6 +261,20 @@ class Manifest(ASAPBaseModel):
231
261
  raise ValueError(f"Invalid semantic version '{v}': {e}") from e
232
262
  return v
233
263
 
264
+ @model_validator(mode="after")
265
+ def validate_auth_schemes(self) -> "Manifest":
266
+ """Validate that all authentication schemes are supported.
267
+
268
+ Raises:
269
+ UnsupportedAuthSchemeError: If any scheme in auth.schemes is not supported
270
+
271
+ Returns:
272
+ Self (for method chaining)
273
+ """
274
+ if self.auth is not None:
275
+ _validate_auth_scheme(self.auth)
276
+ return self
277
+
234
278
 
235
279
  class Conversation(ASAPBaseModel):
236
280
  """A context for related interactions between agents.
@@ -275,6 +319,7 @@ class Task(ASAPBaseModel):
275
319
  conversation_id: ID of the conversation this task belongs to
276
320
  parent_task_id: Optional ID of parent task (for subtasks)
277
321
  status: Current task status (submitted, working, completed, etc.)
322
+ depth: Nesting depth (0 = root); must be ≤ MAX_TASK_DEPTH to prevent infinite recursion
278
323
  progress: Optional progress information (percent, message, ETA)
279
324
  created_at: Timestamp when the task was created (UTC)
280
325
  updated_at: Timestamp of last status update (UTC)
@@ -295,6 +340,12 @@ class Task(ASAPBaseModel):
295
340
  conversation_id: ConversationID = Field(..., description="Parent conversation ID")
296
341
  parent_task_id: TaskID | None = Field(default=None, description="Parent task ID for subtasks")
297
342
  status: TaskStatus = Field(..., description="Task status (submitted, working, etc.)")
343
+ depth: int = Field(
344
+ 0,
345
+ ge=0,
346
+ le=MAX_TASK_DEPTH,
347
+ description="Nesting depth for subtasks (0 = root); prevents infinite recursion",
348
+ )
298
349
  progress: dict[str, Any] | None = Field(
299
350
  default=None, description="Progress info (percent, message, ETA)"
300
351
  )
asap/models/envelope.py CHANGED
@@ -12,6 +12,7 @@ from pydantic import Field, field_validator, model_validator
12
12
  from asap.models.base import ASAPBaseModel
13
13
  from asap.models.ids import generate_id
14
14
  from asap.models.types import AgentURN
15
+ from asap.models.validators import validate_agent_urn
15
16
 
16
17
 
17
18
  class Envelope(ASAPBaseModel):
@@ -65,7 +66,13 @@ class Envelope(ASAPBaseModel):
65
66
  default=None, description="Optional trace ID for distributed tracing"
66
67
  )
67
68
  extensions: dict[str, Any] | None = Field(
68
- default=None, description="Optional custom extensions"
69
+ default=None,
70
+ description=(
71
+ "Optional custom extensions. "
72
+ "Can include a 'nonce' field (string) for replay attack prevention. "
73
+ "If provided, the nonce must be unique within the TTL window (typically 10 minutes). "
74
+ "Duplicate nonces will be rejected by the validation layer."
75
+ ),
69
76
  )
70
77
 
71
78
  @field_validator("id", mode="before")
@@ -84,6 +91,12 @@ class Envelope(ASAPBaseModel):
84
91
  return datetime.now(timezone.utc)
85
92
  return v
86
93
 
94
+ @field_validator("sender", "recipient")
95
+ @classmethod
96
+ def validate_sender_recipient_urn(cls, v: str) -> str:
97
+ """Validate sender/recipient URN format and length."""
98
+ return validate_agent_urn(v)
99
+
87
100
  @model_validator(mode="after")
88
101
  def validate_response_correlation(self) -> "Envelope":
89
102
  """Validate that response payloads have correlation_id for request tracking."""
asap/models/ids.py CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  ULIDs (Universally Unique Lexicographically Sortable Identifiers) provide:
4
4
  - 128-bit compatibility with UUID
5
- - Lexicographic sorting by timestamp
5
+ - Lexicographic sorting by creation time when timestamps differ (millisecond precision)
6
6
  - Canonically encoded as 26-character string (Crockford's Base32)
7
- - Monotonically increasing within the same millisecond
7
+
8
+ Note: Order is guaranteed only across different milliseconds. Two ULIDs generated
9
+ within the same millisecond share the same timestamp prefix; their lexicographic
10
+ order is then determined by the random component and may not match generation order.
8
11
  """
9
12
 
10
13
  from datetime import datetime
@@ -18,7 +21,8 @@ def generate_id() -> str:
18
21
  Returns:
19
22
  A 26-character ULID string that is:
20
23
  - Globally unique
21
- - Lexicographically sortable by creation time
24
+ - Lexicographically sortable by creation time when generated in different
25
+ milliseconds (within the same millisecond, order is not guaranteed)
22
26
  - URL-safe (uses Crockford's Base32 alphabet)
23
27
 
24
28
  Example:
@@ -26,7 +30,7 @@ def generate_id() -> str:
26
30
  >>> len(id1)
27
31
  26
28
32
  >>> id2 = generate_id()
29
- >>> id1 < id2 # Sortable by time
33
+ >>> id1 < id2 # True when timestamps differ (e.g. different ms)
30
34
  True
31
35
  """
32
36
  return str(ULID())