acp-sdk 0.0.1__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 (57) hide show
  1. acp/__init__.py +138 -0
  2. acp/cli/__init__.py +6 -0
  3. acp/cli/claude.py +139 -0
  4. acp/cli/cli.py +471 -0
  5. acp/client/__init__.py +0 -0
  6. acp/client/__main__.py +79 -0
  7. acp/client/session.py +372 -0
  8. acp/client/sse.py +142 -0
  9. acp/client/stdio.py +153 -0
  10. acp/py.typed +0 -0
  11. acp/server/__init__.py +3 -0
  12. acp/server/__main__.py +50 -0
  13. acp/server/highlevel/__init__.py +9 -0
  14. acp/server/highlevel/agents/__init__.py +5 -0
  15. acp/server/highlevel/agents/agent_manager.py +110 -0
  16. acp/server/highlevel/agents/base.py +22 -0
  17. acp/server/highlevel/agents/templates.py +23 -0
  18. acp/server/highlevel/context.py +185 -0
  19. acp/server/highlevel/exceptions.py +25 -0
  20. acp/server/highlevel/prompts/__init__.py +4 -0
  21. acp/server/highlevel/prompts/base.py +167 -0
  22. acp/server/highlevel/prompts/manager.py +50 -0
  23. acp/server/highlevel/prompts/prompt_manager.py +33 -0
  24. acp/server/highlevel/resources/__init__.py +23 -0
  25. acp/server/highlevel/resources/base.py +48 -0
  26. acp/server/highlevel/resources/resource_manager.py +94 -0
  27. acp/server/highlevel/resources/templates.py +80 -0
  28. acp/server/highlevel/resources/types.py +185 -0
  29. acp/server/highlevel/server.py +704 -0
  30. acp/server/highlevel/tools/__init__.py +4 -0
  31. acp/server/highlevel/tools/base.py +83 -0
  32. acp/server/highlevel/tools/tool_manager.py +53 -0
  33. acp/server/highlevel/utilities/__init__.py +1 -0
  34. acp/server/highlevel/utilities/func_metadata.py +210 -0
  35. acp/server/highlevel/utilities/logging.py +43 -0
  36. acp/server/highlevel/utilities/types.py +54 -0
  37. acp/server/lowlevel/__init__.py +3 -0
  38. acp/server/lowlevel/helper_types.py +9 -0
  39. acp/server/lowlevel/server.py +628 -0
  40. acp/server/models.py +17 -0
  41. acp/server/session.py +315 -0
  42. acp/server/sse.py +175 -0
  43. acp/server/stdio.py +83 -0
  44. acp/server/websocket.py +61 -0
  45. acp/shared/__init__.py +0 -0
  46. acp/shared/context.py +14 -0
  47. acp/shared/exceptions.py +14 -0
  48. acp/shared/memory.py +87 -0
  49. acp/shared/progress.py +40 -0
  50. acp/shared/session.py +409 -0
  51. acp/shared/version.py +3 -0
  52. acp/types.py +1258 -0
  53. acp_sdk-0.0.1.dist-info/METADATA +46 -0
  54. acp_sdk-0.0.1.dist-info/RECORD +57 -0
  55. acp_sdk-0.0.1.dist-info/WHEEL +4 -0
  56. acp_sdk-0.0.1.dist-info/entry_points.txt +2 -0
  57. acp_sdk-0.0.1.dist-info/licenses/LICENSE +22 -0
acp/shared/memory.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ In-memory transports
3
+ """
4
+
5
+ from contextlib import asynccontextmanager
6
+ from datetime import timedelta
7
+ from typing import AsyncGenerator
8
+
9
+ import anyio
10
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
11
+
12
+ from acp.client.session import ClientSession
13
+ from acp.server import Server
14
+ from acp.types import JSONRPCMessage
15
+
16
+ MessageStream = tuple[
17
+ MemoryObjectReceiveStream[JSONRPCMessage | Exception],
18
+ MemoryObjectSendStream[JSONRPCMessage],
19
+ ]
20
+
21
+
22
+ @asynccontextmanager
23
+ async def create_client_server_memory_streams() -> (
24
+ AsyncGenerator[tuple[MessageStream, MessageStream], None]
25
+ ):
26
+ """
27
+ Creates a pair of bidirectional memory streams for client-server communication.
28
+
29
+ Returns:
30
+ A tuple of (client_streams, server_streams) where each is a tuple of
31
+ (read_stream, write_stream)
32
+ """
33
+ # Create streams for both directions
34
+ server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
35
+ JSONRPCMessage | Exception
36
+ ](1)
37
+ client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
38
+ JSONRPCMessage | Exception
39
+ ](1)
40
+
41
+ client_streams = (server_to_client_receive, client_to_server_send)
42
+ server_streams = (client_to_server_receive, server_to_client_send)
43
+
44
+ async with (
45
+ server_to_client_receive,
46
+ client_to_server_send,
47
+ client_to_server_receive,
48
+ server_to_client_send,
49
+ ):
50
+ yield client_streams, server_streams
51
+
52
+
53
+ @asynccontextmanager
54
+ async def create_connected_server_and_client_session(
55
+ server: Server,
56
+ read_timeout_seconds: timedelta | None = None,
57
+ raise_exceptions: bool = False,
58
+ ) -> AsyncGenerator[ClientSession, None]:
59
+ """Creates a ClientSession that is connected to a running MCP server."""
60
+ async with create_client_server_memory_streams() as (
61
+ client_streams,
62
+ server_streams,
63
+ ):
64
+ client_read, client_write = client_streams
65
+ server_read, server_write = server_streams
66
+
67
+ # Create a cancel scope for the server task
68
+ async with anyio.create_task_group() as tg:
69
+ tg.start_soon(
70
+ lambda: server.run(
71
+ server_read,
72
+ server_write,
73
+ server.create_initialization_options(),
74
+ raise_exceptions=raise_exceptions,
75
+ )
76
+ )
77
+
78
+ try:
79
+ async with ClientSession(
80
+ read_stream=client_read,
81
+ write_stream=client_write,
82
+ read_timeout_seconds=read_timeout_seconds,
83
+ ) as client_session:
84
+ await client_session.initialize()
85
+ yield client_session
86
+ finally:
87
+ tg.cancel_scope.cancel()
acp/shared/progress.py ADDED
@@ -0,0 +1,40 @@
1
+ from contextlib import contextmanager
2
+ from dataclasses import dataclass, field
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from acp.shared.context import RequestContext
7
+ from acp.shared.session import BaseSession
8
+ from acp.types import ProgressToken
9
+
10
+
11
+ class Progress(BaseModel):
12
+ progress: float
13
+ total: float | None
14
+
15
+
16
+ @dataclass
17
+ class ProgressContext:
18
+ session: BaseSession
19
+ progress_token: ProgressToken
20
+ total: float | None
21
+ current: float = field(default=0.0, init=False)
22
+
23
+ async def progress(self, amount: float) -> None:
24
+ self.current += amount
25
+
26
+ await self.session.send_progress_notification(
27
+ self.progress_token, self.current, total=self.total
28
+ )
29
+
30
+
31
+ @contextmanager
32
+ def progress(ctx: RequestContext, total: float | None = None):
33
+ if ctx.meta is None or ctx.meta.progressToken is None:
34
+ raise ValueError("No progress token provided")
35
+
36
+ progress_ctx = ProgressContext(ctx.session, ctx.meta.progressToken, total)
37
+ try:
38
+ yield progress_ctx
39
+ finally:
40
+ pass
acp/shared/session.py ADDED
@@ -0,0 +1,409 @@
1
+ import logging
2
+ from contextlib import AbstractAsyncContextManager
3
+ from datetime import timedelta
4
+ from typing import Any, Callable, Generic, TypeVar
5
+
6
+ import anyio
7
+ import anyio.lowlevel
8
+ import httpx
9
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
10
+ from opentelemetry import trace
11
+ from opentelemetry.baggage.propagation import W3CBaggagePropagator
12
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
13
+ from pydantic import BaseModel
14
+
15
+ from acp.shared.exceptions import McpError
16
+ from acp.types import (
17
+ CancelledNotification,
18
+ ClientNotification,
19
+ ClientRequest,
20
+ ClientResult,
21
+ ErrorData,
22
+ JSONRPCError,
23
+ JSONRPCMessage,
24
+ JSONRPCNotification,
25
+ JSONRPCRequest,
26
+ JSONRPCResponse,
27
+ RequestParams,
28
+ ServerNotification,
29
+ ServerRequest,
30
+ ServerResult,
31
+ )
32
+
33
+ SendRequestT = TypeVar("SendRequestT", ClientRequest, ServerRequest)
34
+ SendResultT = TypeVar("SendResultT", ClientResult, ServerResult)
35
+ SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification)
36
+ ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest)
37
+ ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
38
+ ReceiveNotificationT = TypeVar(
39
+ "ReceiveNotificationT", ClientNotification, ServerNotification
40
+ )
41
+
42
+ RequestId = str | int
43
+
44
+
45
+ class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
46
+ """Handles responding to MCP requests and manages request lifecycle.
47
+
48
+ This class MUST be used as a context manager to ensure proper cleanup and
49
+ cancellation handling:
50
+
51
+ Example:
52
+ with request_responder as resp:
53
+ await resp.respond(result)
54
+
55
+ The context manager ensures:
56
+ 1. Proper cancellation scope setup and cleanup
57
+ 2. Request completion tracking
58
+ 3. Cleanup of in-flight requests
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ request_id: RequestId,
64
+ request_meta: RequestParams.Meta | None,
65
+ request: ReceiveRequestT,
66
+ session: "BaseSession",
67
+ on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any],
68
+ ) -> None:
69
+ self.request_id = request_id
70
+ self.request_meta = request_meta
71
+ self.request = request
72
+ self._session = session
73
+ self._completed = False
74
+ self._cancel_scope = anyio.CancelScope()
75
+ self._on_complete = on_complete
76
+ self._entered = False # Track if we're in a context manager
77
+
78
+ def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]":
79
+ """Enter the context manager, enabling request cancellation tracking."""
80
+ self._entered = True
81
+ self._cancel_scope = anyio.CancelScope()
82
+ self._cancel_scope.__enter__()
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
86
+ """Exit the context manager, performing cleanup and notifying completion."""
87
+ try:
88
+ if self._completed:
89
+ self._on_complete(self)
90
+ finally:
91
+ self._entered = False
92
+ if not self._cancel_scope:
93
+ raise RuntimeError("No active cancel scope")
94
+ self._cancel_scope.__exit__(exc_type, exc_val, exc_tb)
95
+
96
+ async def respond(self, response: SendResultT | ErrorData) -> None:
97
+ """Send a response for this request.
98
+
99
+ Must be called within a context manager block.
100
+ Raises:
101
+ RuntimeError: If not used within a context manager
102
+ AssertionError: If request was already responded to
103
+ """
104
+ if not self._entered:
105
+ raise RuntimeError("RequestResponder must be used as a context manager")
106
+ assert not self._completed, "Request already responded to"
107
+
108
+ if not self.cancelled:
109
+ self._completed = True
110
+
111
+ await self._session._send_response(
112
+ request_id=self.request_id, response=response
113
+ )
114
+
115
+ async def cancel(self) -> None:
116
+ """Cancel this request and mark it as completed."""
117
+ if not self._entered:
118
+ raise RuntimeError("RequestResponder must be used as a context manager")
119
+ if not self._cancel_scope:
120
+ raise RuntimeError("No active cancel scope")
121
+
122
+ self._cancel_scope.cancel()
123
+ self._completed = True # Mark as completed so it's removed from in_flight
124
+ # Send an error response to indicate cancellation
125
+ await self._session._send_response(
126
+ request_id=self.request_id,
127
+ response=ErrorData(code=0, message="Request cancelled", data=None),
128
+ )
129
+
130
+ @property
131
+ def in_flight(self) -> bool:
132
+ return not self._completed and not self.cancelled
133
+
134
+ @property
135
+ def cancelled(self) -> bool:
136
+ return self._cancel_scope is not None and self._cancel_scope.cancel_called
137
+
138
+
139
+ class BaseSession(
140
+ AbstractAsyncContextManager,
141
+ Generic[
142
+ SendRequestT,
143
+ SendNotificationT,
144
+ SendResultT,
145
+ ReceiveRequestT,
146
+ ReceiveNotificationT,
147
+ ],
148
+ ):
149
+ """
150
+ Implements an MCP "session" on top of read/write streams, including features
151
+ like request/response linking, notifications, and progress.
152
+
153
+ This class is an async context manager that automatically starts processing
154
+ messages when entered.
155
+ """
156
+
157
+ _response_streams: dict[
158
+ RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError]
159
+ ]
160
+ _request_id: int
161
+ _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]]
162
+
163
+ def __init__(
164
+ self,
165
+ read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception],
166
+ write_stream: MemoryObjectSendStream[JSONRPCMessage],
167
+ receive_request_type: type[ReceiveRequestT],
168
+ receive_notification_type: type[ReceiveNotificationT],
169
+ # If none, reading will never time out
170
+ read_timeout_seconds: timedelta | None = None,
171
+ ) -> None:
172
+ self._read_stream = read_stream
173
+ self._write_stream = write_stream
174
+ self._response_streams = {}
175
+ self._request_id = 0
176
+ self._receive_request_type = receive_request_type
177
+ self._receive_notification_type = receive_notification_type
178
+ self._read_timeout_seconds = read_timeout_seconds
179
+ self._in_flight = {}
180
+
181
+ self._incoming_message_stream_writer, self._incoming_message_stream_reader = (
182
+ anyio.create_memory_object_stream[
183
+ RequestResponder[ReceiveRequestT, SendResultT]
184
+ | ReceiveNotificationT
185
+ | Exception
186
+ ]()
187
+ )
188
+
189
+ async def __aenter__(self):
190
+ self._task_group = anyio.create_task_group()
191
+ await self._task_group.__aenter__()
192
+ self._task_group.start_soon(self._receive_loop)
193
+ return self
194
+
195
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
196
+ # Using BaseSession as a context manager should not block on exit (this
197
+ # would be very surprising behavior), so make sure to cancel the tasks
198
+ # in the task group.
199
+ self._task_group.cancel_scope.cancel()
200
+ return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
201
+
202
+ async def send_request(
203
+ self,
204
+ request: SendRequestT,
205
+ result_type: type[ReceiveResultT],
206
+ ) -> ReceiveResultT:
207
+ """
208
+ Sends a request and wait for a response. Raises an McpError if the
209
+ response contains an error.
210
+
211
+ Do not use this method to emit notifications! Use send_notification()
212
+ instead.
213
+ """
214
+
215
+ request_id = self._request_id
216
+ self._request_id = request_id + 1
217
+
218
+ response_stream, response_stream_reader = anyio.create_memory_object_stream[
219
+ JSONRPCResponse | JSONRPCError
220
+ ](1)
221
+ self._response_streams[request_id] = response_stream
222
+
223
+ jsonrpc_request = JSONRPCRequest(
224
+ jsonrpc="2.0",
225
+ id=request_id,
226
+ **request.model_dump(by_alias=True, mode="json", exclude_none=True),
227
+ )
228
+
229
+ with trace.get_tracer(__name__).start_as_current_span(jsonrpc_request.method):
230
+ meta = (
231
+ jsonrpc_request.params.get("_meta", {})
232
+ if jsonrpc_request.params is not None
233
+ else {}
234
+ )
235
+ W3CBaggagePropagator().inject(meta)
236
+ TraceContextTextMapPropagator().inject(meta)
237
+ if jsonrpc_request.params is None:
238
+ jsonrpc_request.params = {}
239
+ jsonrpc_request.params["_meta"] = meta
240
+
241
+ # TODO: Support progress callbacks
242
+
243
+ await self._write_stream.send(JSONRPCMessage(jsonrpc_request))
244
+
245
+ try:
246
+ with anyio.fail_after(
247
+ None
248
+ if self._read_timeout_seconds is None
249
+ else self._read_timeout_seconds.total_seconds()
250
+ ):
251
+ response_or_error = await response_stream_reader.receive()
252
+ except TimeoutError:
253
+ raise McpError(
254
+ ErrorData(
255
+ code=httpx.codes.REQUEST_TIMEOUT,
256
+ message=(
257
+ f"Timed out while waiting for response to "
258
+ f"{request.__class__.__name__}. Waited "
259
+ f"{self._read_timeout_seconds} seconds."
260
+ ),
261
+ )
262
+ )
263
+
264
+ if isinstance(response_or_error, JSONRPCError):
265
+ raise McpError(response_or_error.error)
266
+ else:
267
+ return result_type.model_validate(response_or_error.result)
268
+
269
+ async def send_notification(self, notification: SendNotificationT) -> None:
270
+ """
271
+ Emits a notification, which is a one-way message that does not expect
272
+ a response.
273
+ """
274
+ jsonrpc_notification = JSONRPCNotification(
275
+ jsonrpc="2.0",
276
+ **notification.model_dump(by_alias=True, mode="json", exclude_none=True),
277
+ )
278
+
279
+ await self._write_stream.send(JSONRPCMessage(jsonrpc_notification))
280
+
281
+ async def _send_response(
282
+ self, request_id: RequestId, response: SendResultT | ErrorData
283
+ ) -> None:
284
+ if isinstance(response, ErrorData):
285
+ jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response)
286
+ await self._write_stream.send(JSONRPCMessage(jsonrpc_error))
287
+ else:
288
+ jsonrpc_response = JSONRPCResponse(
289
+ jsonrpc="2.0",
290
+ id=request_id,
291
+ result=response.model_dump(
292
+ by_alias=True, mode="json", exclude_none=True
293
+ ),
294
+ )
295
+ await self._write_stream.send(JSONRPCMessage(jsonrpc_response))
296
+
297
+ async def _receive_loop(self) -> None:
298
+ async with (
299
+ self._read_stream,
300
+ self._write_stream,
301
+ self._incoming_message_stream_writer,
302
+ ):
303
+ async for message in self._read_stream:
304
+ if isinstance(message, Exception):
305
+ await self._incoming_message_stream_writer.send(message)
306
+ elif isinstance(message.root, JSONRPCRequest):
307
+ validated_request = self._receive_request_type.model_validate(
308
+ message.root.model_dump(
309
+ by_alias=True, mode="json", exclude_none=True
310
+ )
311
+ )
312
+
313
+ responder = RequestResponder(
314
+ request_id=message.root.id,
315
+ request_meta=validated_request.root.params.meta
316
+ if validated_request.root.params
317
+ else None,
318
+ request=validated_request,
319
+ session=self,
320
+ on_complete=lambda r: self._in_flight.pop(r.request_id, None),
321
+ )
322
+
323
+ meta = (
324
+ responder.request_meta.model_dump()
325
+ if responder.request_meta
326
+ else {}
327
+ )
328
+ ctx = TraceContextTextMapPropagator().extract(carrier=meta)
329
+ ctx = W3CBaggagePropagator().extract(carrier=meta, context=ctx)
330
+ with trace.get_tracer(__name__).start_span(
331
+ validated_request.root.method,
332
+ context=ctx,
333
+ kind=trace.SpanKind.SERVER,
334
+ ):
335
+ self._in_flight[responder.request_id] = responder
336
+ await self._received_request(responder)
337
+
338
+ if not responder._completed:
339
+ await self._incoming_message_stream_writer.send(responder)
340
+
341
+ elif isinstance(message.root, JSONRPCNotification):
342
+ try:
343
+ notification = self._receive_notification_type.model_validate(
344
+ message.root.model_dump(
345
+ by_alias=True, mode="json", exclude_none=True
346
+ )
347
+ )
348
+ # Handle cancellation notifications
349
+ if isinstance(notification.root, CancelledNotification):
350
+ cancelled_id = notification.root.params.requestId
351
+ if cancelled_id in self._in_flight:
352
+ await self._in_flight[cancelled_id].cancel()
353
+ else:
354
+ await self._received_notification(notification)
355
+ await self._incoming_message_stream_writer.send(
356
+ notification
357
+ )
358
+ except Exception as e:
359
+ # For other validation errors, log and continue
360
+ logging.warning(
361
+ f"Failed to validate notification: {e}. "
362
+ f"Message was: {message.root}"
363
+ )
364
+ else: # Response or error
365
+ stream = self._response_streams.pop(message.root.id, None)
366
+ if stream:
367
+ await stream.send(message.root)
368
+ else:
369
+ await self._incoming_message_stream_writer.send(
370
+ RuntimeError(
371
+ "Received response with an unknown "
372
+ f"request ID: {message}"
373
+ )
374
+ )
375
+
376
+ async def _received_request(
377
+ self, responder: RequestResponder[ReceiveRequestT, SendResultT]
378
+ ) -> None:
379
+ """
380
+ Can be overridden by subclasses to handle a request without needing to
381
+ listen on the message stream.
382
+
383
+ If the request is responded to within this method, it will not be
384
+ forwarded on to the message stream.
385
+ """
386
+
387
+ async def _received_notification(self, notification: ReceiveNotificationT) -> None:
388
+ """
389
+ Can be overridden by subclasses to handle a notification without needing
390
+ to listen on the message stream.
391
+ """
392
+
393
+ async def send_progress_notification(
394
+ self, progress_token: str | int, progress: float, total: float | None = None
395
+ ) -> None:
396
+ """
397
+ Sends a progress notification for a request that is currently being
398
+ processed.
399
+ """
400
+
401
+ @property
402
+ def incoming_messages(
403
+ self,
404
+ ) -> MemoryObjectReceiveStream[
405
+ RequestResponder[ReceiveRequestT, SendResultT]
406
+ | ReceiveNotificationT
407
+ | Exception
408
+ ]:
409
+ return self._incoming_message_stream_reader
acp/shared/version.py ADDED
@@ -0,0 +1,3 @@
1
+ from acp.types import LATEST_PROTOCOL_VERSION
2
+
3
+ SUPPORTED_PROTOCOL_VERSIONS = [1, LATEST_PROTOCOL_VERSION]