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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- 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 +9 -4
- 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/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- 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 +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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())
|