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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/examples/README.md +81 -13
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +0 -2
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +3 -1
- asap/models/entities.py +21 -6
- asap/models/envelope.py +7 -0
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +28 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +9 -8
- asap/transport/client.py +418 -36
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +58 -34
- asap/transport/server.py +429 -139
- asap/transport/validators.py +0 -4
- asap/utils/sanitization.py +0 -5
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.5.0.dist-info/METADATA +0 -244
- asap_protocol-0.5.0.dist-info/RECORD +0 -41
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
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
|
|
5
|
+
- Lexicographic sorting by creation time when timestamps differ (millisecond precision)
|
|
6
6
|
- Canonically encoded as 26-character string (Crockford's Base32)
|
|
7
|
-
|
|
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 #
|
|
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 (
|
|
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(
|
|
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": "
|
|
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
|
asap/observability/__init__.py
CHANGED
|
@@ -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).
|