asap-protocol 0.5.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 (59) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/examples/README.md +81 -13
  4. asap/examples/auth_patterns.py +212 -0
  5. asap/examples/error_recovery.py +248 -0
  6. asap/examples/long_running.py +287 -0
  7. asap/examples/mcp_integration.py +240 -0
  8. asap/examples/multi_step_workflow.py +134 -0
  9. asap/examples/orchestration.py +293 -0
  10. asap/examples/rate_limiting.py +137 -0
  11. asap/examples/run_demo.py +0 -2
  12. asap/examples/secure_handler.py +84 -0
  13. asap/examples/state_migration.py +240 -0
  14. asap/examples/streaming_response.py +108 -0
  15. asap/examples/websocket_concept.py +129 -0
  16. asap/mcp/__init__.py +43 -0
  17. asap/mcp/client.py +224 -0
  18. asap/mcp/protocol.py +179 -0
  19. asap/mcp/server.py +333 -0
  20. asap/mcp/server_runner.py +40 -0
  21. asap/models/base.py +0 -3
  22. asap/models/constants.py +3 -1
  23. asap/models/entities.py +21 -6
  24. asap/models/envelope.py +7 -0
  25. asap/models/ids.py +8 -4
  26. asap/models/parts.py +33 -3
  27. asap/models/validators.py +16 -0
  28. asap/observability/__init__.py +6 -0
  29. asap/observability/dashboards/README.md +24 -0
  30. asap/observability/dashboards/asap-detailed.json +131 -0
  31. asap/observability/dashboards/asap-red.json +129 -0
  32. asap/observability/logging.py +81 -1
  33. asap/observability/metrics.py +15 -1
  34. asap/observability/trace_parser.py +238 -0
  35. asap/observability/trace_ui.py +218 -0
  36. asap/observability/tracing.py +293 -0
  37. asap/state/machine.py +15 -2
  38. asap/state/snapshot.py +0 -9
  39. asap/testing/__init__.py +31 -0
  40. asap/testing/assertions.py +108 -0
  41. asap/testing/fixtures.py +113 -0
  42. asap/testing/mocks.py +152 -0
  43. asap/transport/__init__.py +28 -0
  44. asap/transport/cache.py +180 -0
  45. asap/transport/circuit_breaker.py +9 -8
  46. asap/transport/client.py +418 -36
  47. asap/transport/compression.py +389 -0
  48. asap/transport/handlers.py +106 -53
  49. asap/transport/middleware.py +58 -34
  50. asap/transport/server.py +429 -139
  51. asap/transport/validators.py +0 -4
  52. asap/utils/sanitization.py +0 -5
  53. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  54. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  55. asap_protocol-0.5.0.dist-info/METADATA +0 -244
  56. asap_protocol-0.5.0.dist-info/RECORD +0 -41
  57. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  58. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  59. {asap_protocol-0.5.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/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
@@ -42,8 +42,10 @@ The 2x multiplier ensures that:
42
42
  - There's a buffer for clock skew and processing delays
43
43
  """
44
44
 
45
- # URN patterns
45
+ # URN patterns and limits
46
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."""
47
49
 
48
50
  # Authentication schemes
49
51
  SUPPORTED_AUTH_SCHEMES = frozenset({"bearer", "basic"})
asap/models/entities.py CHANGED
@@ -14,7 +14,6 @@ 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
 
@@ -23,7 +22,12 @@ from pydantic import Field, field_validator, model_validator
23
22
 
24
23
  from asap.errors import UnsupportedAuthSchemeError
25
24
  from asap.models.base import ASAPBaseModel
26
- from asap.models.constants import AGENT_URN_PATTERN, ASAP_PROTOCOL_VERSION, SUPPORTED_AUTH_SCHEMES
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
27
31
  from asap.models.enums import MessageRole, TaskStatus
28
32
  from asap.models.types import (
29
33
  AgentURN,
@@ -191,6 +195,12 @@ class Agent(ASAPBaseModel):
191
195
  manifest_uri: str = Field(..., description="URL to agent's manifest")
192
196
  capabilities: list[str] = Field(..., min_length=1, description="Agent capability strings")
193
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
+
194
204
 
195
205
  class Manifest(ASAPBaseModel):
196
206
  """Self-describing metadata about an agent's capabilities.
@@ -238,10 +248,8 @@ class Manifest(ASAPBaseModel):
238
248
  @field_validator("id")
239
249
  @classmethod
240
250
  def validate_urn_format(cls, v: str) -> str:
241
- """Validate that agent ID follows URN format."""
242
- if not re.match(AGENT_URN_PATTERN, v):
243
- raise ValueError(f"Agent ID must follow URN format 'urn:asap:agent:{{name}}', got: {v}")
244
- return v
251
+ """Validate that agent ID follows URN format and length limits."""
252
+ return validate_agent_urn(v)
245
253
 
246
254
  @field_validator("version")
247
255
  @classmethod
@@ -311,6 +319,7 @@ class Task(ASAPBaseModel):
311
319
  conversation_id: ID of the conversation this task belongs to
312
320
  parent_task_id: Optional ID of parent task (for subtasks)
313
321
  status: Current task status (submitted, working, completed, etc.)
322
+ depth: Nesting depth (0 = root); must be ≤ MAX_TASK_DEPTH to prevent infinite recursion
314
323
  progress: Optional progress information (percent, message, ETA)
315
324
  created_at: Timestamp when the task was created (UTC)
316
325
  updated_at: Timestamp of last status update (UTC)
@@ -331,6 +340,12 @@ class Task(ASAPBaseModel):
331
340
  conversation_id: ConversationID = Field(..., description="Parent conversation ID")
332
341
  parent_task_id: TaskID | None = Field(default=None, description="Parent task ID for subtasks")
333
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
+ )
334
349
  progress: dict[str, Any] | None = Field(
335
350
  default=None, description="Progress info (percent, message, ETA)"
336
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):
@@ -90,6 +91,12 @@ class Envelope(ASAPBaseModel):
90
91
  return datetime.now(timezone.utc)
91
92
  return v
92
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
+
93
100
  @model_validator(mode="after")
94
101
  def validate_response_correlation(self) -> "Envelope":
95
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())
asap/models/parts.py CHANGED
@@ -14,6 +14,10 @@ from pydantic import Discriminator, Field, TypeAdapter, field_validator
14
14
  from asap.models.base import ASAPBaseModel
15
15
  from asap.models.types import MIMEType
16
16
 
17
+ # Security: reject URIs that could lead to path traversal or local file access
18
+ PATH_TRAVERSAL_PATTERN = re.compile(r"\.\.")
19
+ FILE_URI_PREFIX = "file://"
20
+
17
21
 
18
22
  class TextPart(ASAPBaseModel):
19
23
  """Plain text content part.
@@ -70,7 +74,7 @@ class FilePart(ASAPBaseModel):
70
74
 
71
75
  Attributes:
72
76
  type: Discriminator field, always "file"
73
- uri: File URI (can be asap://, file://, https://, or data: URI)
77
+ uri: File URI (asap://, https://, or data:; file:// and path traversal rejected)
74
78
  mime_type: MIME type of the file (e.g., "application/pdf")
75
79
  inline_data: Optional base64-encoded inline file data
76
80
 
@@ -91,12 +95,38 @@ class FilePart(ASAPBaseModel):
91
95
  """
92
96
 
93
97
  type: Literal["file"] = Field(..., description="Part type discriminator")
94
- uri: str = Field(..., description="File URI (asap://, file://, https://, data:)")
98
+ uri: str = Field(
99
+ ...,
100
+ description="File URI (asap://, https://, data:; file:// and .. rejected)",
101
+ )
95
102
  mime_type: MIMEType = Field(..., description="MIME type (e.g., application/pdf)")
96
103
  inline_data: str | None = Field(
97
104
  default=None, description="Optional base64-encoded inline file data"
98
105
  )
99
106
 
107
+ @field_validator("uri")
108
+ @classmethod
109
+ def validate_uri(cls, v: str) -> str:
110
+ """Validate URI: reject path traversal and suspicious file:// URIs.
111
+
112
+ Rejects URIs containing '..' (path traversal) and file:// URIs
113
+ to prevent reading arbitrary server paths from user-supplied input.
114
+
115
+ Args:
116
+ v: URI string to validate
117
+
118
+ Returns:
119
+ The same URI if valid
120
+
121
+ Raises:
122
+ ValueError: If URI contains path traversal or is a file:// URI
123
+ """
124
+ if PATH_TRAVERSAL_PATTERN.search(v):
125
+ raise ValueError(f"URI must not contain path traversal (..): {v!r}")
126
+ if v.strip().lower().startswith(FILE_URI_PREFIX):
127
+ raise ValueError("file:// URIs are not allowed for security (path traversal risk)")
128
+ return v
129
+
100
130
  @field_validator("mime_type")
101
131
  @classmethod
102
132
  def validate_mime_type(cls, v: str) -> str:
@@ -201,7 +231,7 @@ Example:
201
231
  >>> # Deserializes to FilePart
202
232
  >>> file_part = Part.validate_python({
203
233
  ... "type": "file",
204
- ... "uri": "file://test.pdf",
234
+ ... "uri": "https://example.com/doc.pdf",
205
235
  ... "mime_type": "application/pdf"
206
236
  ... })
207
237
  """
@@ -0,0 +1,16 @@
1
+ """Shared validators for ASAP protocol models."""
2
+
3
+ import re
4
+
5
+ from asap.models.constants import AGENT_URN_PATTERN, MAX_URN_LENGTH
6
+
7
+ _AGENT_URN_RE = re.compile(AGENT_URN_PATTERN)
8
+
9
+
10
+ def validate_agent_urn(v: str) -> str:
11
+ """Validate agent URN format and length (pattern + max length)."""
12
+ if len(v) > MAX_URN_LENGTH:
13
+ raise ValueError(f"Agent URN must be at most {MAX_URN_LENGTH} characters, got {len(v)}")
14
+ if not _AGENT_URN_RE.match(v):
15
+ raise ValueError(f"Agent ID must follow URN format 'urn:asap:agent:{{name}}', got: {v}")
16
+ return v
@@ -25,6 +25,9 @@ from asap.observability.logging import (
25
25
  clear_context,
26
26
  configure_logging,
27
27
  get_logger,
28
+ is_debug_log_mode,
29
+ is_debug_mode,
30
+ sanitize_for_logging,
28
31
  )
29
32
  from asap.observability.metrics import (
30
33
  MetricsCollector,
@@ -38,6 +41,9 @@ __all__ = [
38
41
  "configure_logging",
39
42
  "get_logger",
40
43
  "get_metrics",
44
+ "is_debug_log_mode",
45
+ "is_debug_mode",
41
46
  "reset_metrics",
42
47
  "MetricsCollector",
48
+ "sanitize_for_logging",
43
49
  ]
@@ -0,0 +1,24 @@
1
+ # ASAP Grafana Dashboards
2
+
3
+ Pre-built dashboards for ASAP Protocol observability. Use with Prometheus scraping the `/asap/metrics` endpoint.
4
+
5
+ ## Dashboards
6
+
7
+ - **asap-red.json** – RED metrics (Request rate, Error rate, Duration/latency) for ASAP requests.
8
+ - **asap-detailed.json** – Topology, state machine transitions, and circuit breaker status.
9
+
10
+ ## Setup
11
+
12
+ 1. Configure a Prometheus datasource in Grafana (scrape ASAP server at `http://<asap-host>:<port>/asap/metrics`).
13
+ 2. **Provisioning**: Copy the JSON files to Grafana's provisioning path, e.g.:
14
+ - Copy to `<grafana provisioning dir>/dashboards/asap/` and set the provisioning config to load from that directory.
15
+ - Or import manually: Grafana UI → Dashboards → Import → Upload JSON file.
16
+ 3. Select the Prometheus datasource when prompted.
17
+
18
+ ## Metrics used
19
+
20
+ - `asap_requests_total` – Total requests (labels: `payload_type`, `status`).
21
+ - `asap_requests_error_total` – Failed requests.
22
+ - `asap_request_duration_seconds` – Request latency histogram.
23
+ - `asap_state_transitions_total` – State machine transitions (labels: `from_status`, `to_status`).
24
+ - `asap_circuit_breaker_open` – Circuit open state (if exposed by client metrics).