meshagent-anthropic 0.21.0__tar.gz → 0.22.1__tar.gz

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 (27) hide show
  1. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/CHANGELOG.md +14 -0
  2. {meshagent_anthropic-0.21.0/meshagent_anthropic.egg-info → meshagent_anthropic-0.22.1}/PKG-INFO +4 -4
  3. {meshagent_anthropic-0.21.0/meshagent/anthropic/tools → meshagent_anthropic-0.22.1/meshagent/anthropic}/__init__.py +14 -0
  4. meshagent_anthropic-0.22.1/meshagent/anthropic/mcp.py +103 -0
  5. {meshagent_anthropic-0.21.0/meshagent/anthropic/tools → meshagent_anthropic-0.22.1/meshagent/anthropic}/messages_adapter.py +225 -27
  6. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/meshagent/anthropic/proxy/proxy.py +1 -1
  7. meshagent_anthropic-0.22.1/meshagent/anthropic/tests/anthropic_live_test.py +156 -0
  8. meshagent_anthropic-0.22.1/meshagent/anthropic/tests/mcp_test.py +64 -0
  9. meshagent_anthropic-0.22.1/meshagent/anthropic/tests/messages_adapter_test.py +179 -0
  10. meshagent_anthropic-0.22.1/meshagent/anthropic/tests/openai_responses_stream_adapter_test.py +102 -0
  11. meshagent_anthropic-0.22.1/meshagent/anthropic/version.py +1 -0
  12. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1/meshagent_anthropic.egg-info}/PKG-INFO +4 -4
  13. meshagent_anthropic-0.22.1/meshagent_anthropic.egg-info/SOURCES.txt +21 -0
  14. meshagent_anthropic-0.22.1/meshagent_anthropic.egg-info/requires.txt +6 -0
  15. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/pyproject.toml +3 -3
  16. meshagent_anthropic-0.21.0/meshagent/anthropic/__init__.py +0 -13
  17. meshagent_anthropic-0.21.0/meshagent/anthropic/version.py +0 -1
  18. meshagent_anthropic-0.21.0/meshagent_anthropic.egg-info/SOURCES.txt +0 -17
  19. meshagent_anthropic-0.21.0/meshagent_anthropic.egg-info/requires.txt +0 -6
  20. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/LICENSE +0 -0
  21. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/MANIFEST.in +0 -0
  22. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/README.md +0 -0
  23. {meshagent_anthropic-0.21.0/meshagent/anthropic/tools → meshagent_anthropic-0.22.1/meshagent/anthropic}/openai_responses_stream_adapter.py +0 -0
  24. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/meshagent/anthropic/proxy/__init__.py +0 -0
  25. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/meshagent_anthropic.egg-info/dependency_links.txt +0 -0
  26. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/meshagent_anthropic.egg-info/top_level.txt +0 -0
  27. {meshagent_anthropic-0.21.0 → meshagent_anthropic-0.22.1}/setup.cfg +0 -0
@@ -1,3 +1,17 @@
1
+ ## [0.22.1]
2
+ - Stability
3
+
4
+ ## [0.22.0]
5
+ - Added meshagent-anthropic with Anthropic Messages adapter, MCP connector toolkit support, and an OpenAI-Responses-compatible stream adapter (depends on anthropic>=0.25,<1.0).
6
+ - Breaking: agent naming now derives from participant name (Agent.name deprecated; TaskRunner/LLMRunner/Worker/VoiceBot constructors no longer require name; Voicebot alias removed; MailWorker renamed to MailBot with queue default).
7
+ - Breaking: SecretsClient methods renamed to list_secrets/delete_secret and expanded with request_secret/provide_secret/get_secret/set_secret/delete_requested_secret flows.
8
+ - Breaking: Meshagent client create_service/update_service now return ServiceSpec objects; service-template create/update helpers added for project and room services.
9
+ - OpenAI Responses adapter adds context window tracking, compaction via responses.compact, input-token counting, usage storage, max_output_tokens control, and shell tool env injection.
10
+ - RoomClient can auto-initialize from MESHAGENT_ROOM/MESHAGENT_TOKEN; websocket URL helper added.
11
+ - Schema documents add grep/tag queries and ChatBotClient; chat reply routing now targets the requesting participant reliably.
12
+ - Database toolkit now expects update values as a list of column updates and defaults to advanced search/delete tools.
13
+ - Dependency addition: prompt-toolkit~=3.0.52 added to CLI 'all' extras.
14
+
1
15
  ## [0.21.0]
2
16
  - Breaking: the Image model no longer exposes manifest/template metadata in image listings.
3
17
  - Add token-backed environment variables in service specs so Python clients can inject participant tokens instead of static values.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-anthropic
3
- Version: 0.21.0
3
+ Version: 0.22.1
4
4
  Summary: Anthropic Building Blocks for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -11,9 +11,9 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: pytest~=8.4
13
13
  Requires-Dist: pytest-asyncio~=0.26
14
- Requires-Dist: meshagent-api~=0.21.0
15
- Requires-Dist: meshagent-agents~=0.21.0
16
- Requires-Dist: meshagent-tools~=0.21.0
14
+ Requires-Dist: meshagent-api~=0.22.1
15
+ Requires-Dist: meshagent-agents~=0.22.1
16
+ Requires-Dist: meshagent-tools~=0.22.1
17
17
  Requires-Dist: anthropic<1.0,>=0.25
18
18
  Dynamic: license-file
19
19
 
@@ -2,10 +2,24 @@ from .messages_adapter import (
2
2
  AnthropicMessagesAdapter,
3
3
  AnthropicMessagesToolResponseAdapter,
4
4
  )
5
+ from .mcp import (
6
+ MCPConfig,
7
+ MCPServer,
8
+ MCPTool,
9
+ MCPToolConfig,
10
+ MCPToolset,
11
+ MCPToolkitBuilder,
12
+ )
5
13
  from .openai_responses_stream_adapter import AnthropicOpenAIResponsesStreamAdapter
6
14
 
7
15
  __all__ = [
8
16
  AnthropicMessagesAdapter,
9
17
  AnthropicMessagesToolResponseAdapter,
10
18
  AnthropicOpenAIResponsesStreamAdapter,
19
+ MCPConfig,
20
+ MCPServer,
21
+ MCPTool,
22
+ MCPToolConfig,
23
+ MCPToolset,
24
+ MCPToolkitBuilder,
11
25
  ]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from meshagent.tools import BaseTool, Toolkit, ToolkitBuilder, ToolkitConfig
8
+
9
+
10
+ # This module wraps Anthropic's official MCP connector support:
11
+ # https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
12
+
13
+
14
+ MCP_CONNECTOR_BETA = "mcp-client-2025-11-20"
15
+
16
+
17
+ class MCPServer(BaseModel):
18
+ """Anthropic `mcp_servers` entry."""
19
+
20
+ type: Literal["url"] = "url"
21
+ url: str
22
+ name: str
23
+ authorization_token: Optional[str] = None
24
+
25
+
26
+ class MCPToolConfig(BaseModel):
27
+ enabled: Optional[bool] = None
28
+ defer_loading: Optional[bool] = None
29
+
30
+
31
+ class MCPToolset(BaseModel):
32
+ """Anthropic `tools` entry for MCP connector."""
33
+
34
+ type: Literal["mcp_toolset"] = "mcp_toolset"
35
+ mcp_server_name: str
36
+ default_config: Optional[MCPToolConfig] = None
37
+ configs: Optional[dict[str, MCPToolConfig]] = None
38
+
39
+ # Pass-through cache control, if desired.
40
+ cache_control: Optional[dict] = None
41
+
42
+
43
+ class MCPConfig(ToolkitConfig):
44
+ """MeshAgent toolkit config that injects MCP connector params.
45
+
46
+ This is intentionally modeled after the OpenAI adapter's MCP config pattern
47
+ (a toolkit config that can be provided via `tools=[...]` in chat messages),
48
+ but it produces Anthropic-specific request parameters: `mcp_servers` and
49
+ `mcp_toolset` entries.
50
+ """
51
+
52
+ name: Literal["mcp"] = "mcp"
53
+
54
+ mcp_servers: list[MCPServer]
55
+ toolsets: Optional[list[MCPToolset]] = None
56
+ betas: list[str] = [MCP_CONNECTOR_BETA]
57
+
58
+
59
+ class MCPTool(BaseTool):
60
+ """Non-executable tool that augments the Anthropic request."""
61
+
62
+ def __init__(self, *, config: MCPConfig):
63
+ super().__init__(name="mcp")
64
+ self.config = config
65
+
66
+ def apply(self, *, request: dict) -> None:
67
+ """Mutate an Anthropic Messages request in-place."""
68
+
69
+ # Ensure we use the beta Messages API surface.
70
+ betas = request.setdefault("betas", [])
71
+ for b in self.config.betas:
72
+ if b not in betas:
73
+ betas.append(b)
74
+
75
+ toolsets = self.config.toolsets
76
+ if toolsets is None:
77
+ toolsets = [
78
+ MCPToolset(mcp_server_name=s.name) for s in self.config.mcp_servers
79
+ ]
80
+
81
+ # Merge/dedupe servers by name.
82
+ existing_servers = request.setdefault("mcp_servers", [])
83
+ dedup: dict[str, dict] = {
84
+ s["name"]: s
85
+ for s in existing_servers
86
+ if isinstance(s, dict) and isinstance(s.get("name"), str)
87
+ }
88
+ for server in self.config.mcp_servers:
89
+ dedup[server.name] = server.model_dump(mode="json", exclude_none=True)
90
+ request["mcp_servers"] = list(dedup.values())
91
+
92
+ # Anthropic MCP toolsets live inside the top-level `tools` array.
93
+ tools = request.setdefault("tools", [])
94
+ for toolset in toolsets:
95
+ tools.append(toolset.model_dump(mode="json", exclude_none=True))
96
+
97
+
98
+ class MCPToolkitBuilder(ToolkitBuilder):
99
+ def __init__(self):
100
+ super().__init__(name="mcp", type=MCPConfig)
101
+
102
+ async def make(self, *, room, model: str, config: MCPConfig) -> Toolkit:
103
+ return Toolkit(name="mcp", tools=[MCPTool(config=config)])
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from meshagent.agents.agent import AgentChatContext
4
4
  from meshagent.api import RoomClient, RoomException, RemoteParticipant
5
- from meshagent.tools import Toolkit, ToolContext, Tool
5
+ from meshagent.tools import Toolkit, ToolContext, Tool, BaseTool
6
6
  from meshagent.api.messaging import (
7
7
  Response,
8
8
  LinkResponse,
@@ -21,8 +21,10 @@ import os
21
21
  import logging
22
22
  import re
23
23
  import asyncio
24
+ import base64
24
25
 
25
26
  from meshagent.anthropic.proxy import get_client, get_logging_httpx_client
27
+ from meshagent.anthropic.mcp import MCPTool as MCPConnectorTool
26
28
 
27
29
  try:
28
30
  from anthropic import APIStatusError
@@ -150,7 +152,51 @@ class AnthropicMessagesToolResponseAdapter(ToolResponseAdapter):
150
152
  # Allow advanced tools to return pre-built Anthropic blocks.
151
153
  return [{"role": "user", "content": response.outputs}]
152
154
 
153
- output = await self.to_plain_text(room=room, response=response)
155
+ tool_result_content: list[dict]
156
+
157
+ if isinstance(response, FileResponse):
158
+ mime_type = (response.mime_type or "").lower()
159
+
160
+ if mime_type == "image/jpg":
161
+ mime_type = "image/jpeg"
162
+
163
+ if mime_type.startswith("image/"):
164
+ allowed = {"image/jpeg", "image/png", "image/gif", "image/webp"}
165
+ if mime_type not in allowed:
166
+ output = f"{response.name} was returned as {response.mime_type}, which Anthropic does not accept as an image block"
167
+ tool_result_content = [_text_block(output)]
168
+ else:
169
+ tool_result_content = [
170
+ {
171
+ "type": "image",
172
+ "source": {
173
+ "type": "base64",
174
+ "media_type": mime_type,
175
+ "data": base64.b64encode(response.data).decode("utf-8"),
176
+ },
177
+ }
178
+ ]
179
+
180
+ elif mime_type == "application/pdf":
181
+ tool_result_content = [
182
+ {
183
+ "type": "document",
184
+ "title": response.name,
185
+ "source": {
186
+ "type": "base64",
187
+ "media_type": "application/pdf",
188
+ "data": base64.b64encode(response.data).decode("utf-8"),
189
+ },
190
+ }
191
+ ]
192
+
193
+ else:
194
+ output = await self.to_plain_text(room=room, response=response)
195
+ tool_result_content = [_text_block(output)]
196
+
197
+ else:
198
+ output = await self.to_plain_text(room=room, response=response)
199
+ tool_result_content = [_text_block(output)]
154
200
 
155
201
  message = {
156
202
  "role": "user",
@@ -158,7 +204,7 @@ class AnthropicMessagesToolResponseAdapter(ToolResponseAdapter):
158
204
  {
159
205
  "type": "tool_result",
160
206
  "tool_use_id": tool_use_id,
161
- "content": [_text_block(output)],
207
+ "content": tool_result_content,
162
208
  }
163
209
  ],
164
210
  }
@@ -210,31 +256,81 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
210
256
  ) -> tuple[list[dict], Optional[str]]:
211
257
  system = context.get_system_instructions()
212
258
 
259
+ def as_blocks(role: str, content: Any) -> dict:
260
+ if isinstance(content, str):
261
+ return {"role": role, "content": [_text_block(content)]}
262
+ if isinstance(content, list):
263
+ return {"role": role, "content": content}
264
+ return {"role": role, "content": [_text_block(str(content))]}
265
+
213
266
  messages: list[dict] = []
267
+ pending_tool_use_ids: set[str] = set()
268
+
214
269
  for m in context.messages:
215
- if m.get("role") in {"user", "assistant"}:
216
- content = m.get("content")
217
- if isinstance(content, str):
218
- messages.append(
219
- {"role": m["role"], "content": [_text_block(content)]}
270
+ role = m.get("role")
271
+ if role not in {"user", "assistant"}:
272
+ continue
273
+
274
+ msg = as_blocks(role, m.get("content"))
275
+
276
+ # Anthropic requires that tool_result blocks appear in the *immediately next*
277
+ # user message after an assistant tool_use.
278
+ if pending_tool_use_ids:
279
+ if role == "assistant":
280
+ # Drop any assistant chatter that appears between tool_use and tool_result.
281
+ logger.warning(
282
+ "dropping assistant message between tool_use and tool_result"
220
283
  )
221
- elif isinstance(content, list):
222
- # Allow passing through OpenAI-style image/file blocks if already present.
223
- # Tool adapters will also insert Anthropic blocks here.
224
- messages.append({"role": m["role"], "content": content})
225
- else:
226
- messages.append(
227
- {"role": m["role"], "content": [_text_block(str(content))]}
284
+ continue
285
+
286
+ # role == user
287
+ content_blocks = msg.get("content") or []
288
+ tool_results = [
289
+ b
290
+ for b in content_blocks
291
+ if isinstance(b, dict) and b.get("type") == "tool_result"
292
+ ]
293
+ tool_result_ids = {
294
+ b.get("tool_use_id") for b in tool_results if b.get("tool_use_id")
295
+ }
296
+
297
+ if not pending_tool_use_ids.issubset(tool_result_ids):
298
+ # If we can't satisfy the ordering contract, it's better to fail early
299
+ # with a clear error than to send an invalid request.
300
+ raise RoomException(
301
+ "invalid transcript: tool_use blocks must be followed by a user message "
302
+ "containing tool_result blocks for all tool_use ids"
228
303
  )
229
304
 
305
+ pending_tool_use_ids.clear()
306
+
307
+ # Track tool_use ids introduced by assistant messages.
308
+ if role == "assistant":
309
+ content_blocks = msg.get("content") or []
310
+ for b in content_blocks:
311
+ if isinstance(b, dict) and b.get("type") == "tool_use":
312
+ tool_id = b.get("id")
313
+ if tool_id:
314
+ pending_tool_use_ids.add(tool_id)
315
+
316
+ messages.append(msg)
317
+
230
318
  return messages, system
231
319
 
232
- async def _create_with_optional_headers(self, client: Any, **kwargs) -> Any:
320
+ def _messages_api(self, *, client: Any, request: dict) -> Any:
321
+ # The MCP connector requires `client.beta.messages.*`.
322
+ if request.get("betas") is not None:
323
+ return client.beta.messages
324
+ return client.messages
325
+
326
+ async def _create_with_optional_headers(self, *, client: Any, request: dict) -> Any:
327
+ api = self._messages_api(client=client, request=request)
233
328
  try:
234
- return await client.messages.create(**kwargs)
329
+ return await api.create(**request)
235
330
  except TypeError:
236
- kwargs.pop("extra_headers", None)
237
- return await client.messages.create(**kwargs)
331
+ request = dict(request)
332
+ request.pop("extra_headers", None)
333
+ return await api.create(**request)
238
334
 
239
335
  async def _stream_message(
240
336
  self,
@@ -255,9 +351,10 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
255
351
  ```
256
352
  """
257
353
 
258
- stream = client.messages.stream(**request)
354
+ api = self._messages_api(client=client, request=request)
355
+ stream_mgr = api.stream(**request)
259
356
 
260
- async with stream:
357
+ async with stream_mgr as stream:
261
358
  async for event in stream:
262
359
  event_handler({"type": event.type, "event": _as_jsonable(event)})
263
360
 
@@ -267,6 +364,49 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
267
364
  )
268
365
  return final_message
269
366
 
367
+ def _split_toolkits(
368
+ self, *, toolkits: list[Toolkit]
369
+ ) -> tuple[list[Toolkit], list[MCPConnectorTool]]:
370
+ """Split toolkits into executable tools and request middleware tools."""
371
+
372
+ executable_toolkits: list[Toolkit] = []
373
+ middleware: list[MCPConnectorTool] = []
374
+
375
+ for toolkit in toolkits:
376
+ executable_tools: list[Tool] = []
377
+
378
+ for t in toolkit.tools:
379
+ if isinstance(t, MCPConnectorTool):
380
+ middleware.append(t)
381
+ elif isinstance(t, Tool):
382
+ executable_tools.append(t)
383
+ elif isinstance(t, BaseTool):
384
+ # Non-executable tool types are ignored.
385
+ continue
386
+ else:
387
+ raise RoomException(f"unsupported tool type {type(t)}")
388
+
389
+ if executable_tools:
390
+ executable_toolkits.append(
391
+ Toolkit(
392
+ name=toolkit.name,
393
+ title=getattr(toolkit, "title", None),
394
+ description=getattr(toolkit, "description", None),
395
+ thumbnail_url=getattr(toolkit, "thumbnail_url", None),
396
+ rules=getattr(toolkit, "rules", []),
397
+ tools=executable_tools,
398
+ )
399
+ )
400
+
401
+ return executable_toolkits, middleware
402
+
403
+ def _apply_request_middleware(
404
+ self, *, request: dict, middleware: list[MCPConnectorTool]
405
+ ) -> dict:
406
+ for m in middleware:
407
+ m.apply(request=request)
408
+ return request
409
+
270
410
  async def next(
271
411
  self,
272
412
  *,
@@ -291,8 +431,10 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
291
431
 
292
432
  try:
293
433
  while True:
294
- tool_bundle = MessagesToolBundle(toolkits=toolkits)
295
- tools = tool_bundle.to_json()
434
+ executable_toolkits, middleware = self._split_toolkits(
435
+ toolkits=toolkits
436
+ )
437
+ tool_bundle = MessagesToolBundle(toolkits=executable_toolkits)
296
438
 
297
439
  messages, system = self._convert_messages(context=context)
298
440
 
@@ -312,16 +454,45 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
312
454
  on_behalf_of.get_attribute("name")
313
455
  )
314
456
 
457
+ message_options = dict(self._message_options or {})
458
+
459
+ tools_list: list[dict] = tool_bundle.to_json() or []
460
+ extra_tools = message_options.pop("tools", None)
461
+ if isinstance(extra_tools, list):
462
+ tools_list.extend(extra_tools)
463
+
315
464
  request = {
316
465
  "model": model,
317
466
  "max_tokens": self._max_tokens,
318
467
  "messages": messages,
319
468
  "system": system,
320
- "tools": tools,
469
+ "tools": tools_list,
321
470
  "extra_headers": extra_headers or None,
322
- **(self._message_options or {}),
471
+ **message_options,
323
472
  }
324
473
 
474
+ request = self._apply_request_middleware(
475
+ request=request,
476
+ middleware=middleware,
477
+ )
478
+
479
+ # Normalize empty lists to None for Anthropic.
480
+ if (
481
+ isinstance(request.get("tools"), list)
482
+ and len(request["tools"]) == 0
483
+ ):
484
+ request["tools"] = None
485
+ if (
486
+ isinstance(request.get("mcp_servers"), list)
487
+ and len(request["mcp_servers"]) == 0
488
+ ):
489
+ request["mcp_servers"] = None
490
+ if (
491
+ isinstance(request.get("betas"), list)
492
+ and len(request["betas"]) == 0
493
+ ):
494
+ request["betas"] = None
495
+
325
496
  # remove None fields
326
497
  request = {k: v for k, v in request.items() if v is not None}
327
498
 
@@ -336,7 +507,8 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
336
507
  response_dict = _as_jsonable(final_message)
337
508
  else:
338
509
  response = await self._create_with_optional_headers(
339
- client, **request
510
+ client=client,
511
+ request=request,
340
512
  )
341
513
  response_dict = _as_jsonable(response)
342
514
 
@@ -376,9 +548,35 @@ class AnthropicMessagesAdapter(LLMAdapter[dict]):
376
548
  tasks.append(asyncio.create_task(do_tool(tool_use)))
377
549
 
378
550
  results = await asyncio.gather(*tasks)
551
+
552
+ # Anthropic requires tool_result blocks for *all* tool_use ids to appear in the
553
+ # *immediately next* user message after the assistant tool_use message.
554
+ tool_result_blocks: list[dict] = []
555
+ trailing_messages: list[dict] = []
556
+
379
557
  for msgs in results:
380
558
  for msg in msgs:
381
- context.messages.append(msg)
559
+ if (
560
+ isinstance(msg, dict)
561
+ and msg.get("role") == "user"
562
+ and isinstance(msg.get("content"), list)
563
+ and all(
564
+ isinstance(b, dict)
565
+ and b.get("type") == "tool_result"
566
+ for b in msg["content"]
567
+ )
568
+ ):
569
+ tool_result_blocks.extend(msg["content"])
570
+ else:
571
+ trailing_messages.append(msg)
572
+
573
+ if tool_result_blocks:
574
+ context.messages.append(
575
+ {"role": "user", "content": tool_result_blocks}
576
+ )
577
+
578
+ for msg in trailing_messages:
579
+ context.messages.append(msg)
382
580
 
383
581
  continue
384
582
 
@@ -25,7 +25,7 @@ def _redact_headers(headers: httpx.Headers) -> dict:
25
25
  return h
26
26
 
27
27
 
28
- def _truncate_bytes(b: bytes, limit: int = 4000) -> str:
28
+ def _truncate_bytes(b: bytes, limit: int = 1024 * 100) -> str:
29
29
  s = b.decode("utf-8", errors="replace")
30
30
  return (
31
31
  s
@@ -0,0 +1,156 @@
1
+ import os
2
+ import sys
3
+
4
+ import pytest
5
+
6
+ from meshagent.anthropic.messages_adapter import AnthropicMessagesAdapter
7
+ from meshagent.anthropic.mcp import MCPConfig, MCPServer, MCPTool
8
+ from meshagent.agents.agent import AgentChatContext
9
+ from meshagent.tools import Toolkit
10
+
11
+
12
+ def _import_real_anthropic_sdk():
13
+ """Import the external `anthropic` SDK without shadowing.
14
+
15
+ If `pytest` is run from inside `.../meshagent/`, Python may resolve
16
+ `import anthropic` to the local `meshagent/anthropic` package directory.
17
+ """
18
+
19
+ cwd = os.getcwd()
20
+
21
+ if os.path.isdir(os.path.join(cwd, "anthropic")):
22
+ sys.path = [p for p in sys.path if p not in ("", cwd)]
23
+
24
+ import importlib
25
+
26
+ mod = importlib.import_module("anthropic")
27
+
28
+ mod_file = getattr(mod, "__file__", "") or ""
29
+ if mod_file.endswith("/meshagent/anthropic/__init__.py"):
30
+ raise RuntimeError(
31
+ "Imported local `meshagent/anthropic` instead of the Anthropic SDK. "
32
+ "Run pytest from the repo root."
33
+ )
34
+
35
+ return mod
36
+
37
+
38
+ a = _import_real_anthropic_sdk()
39
+
40
+
41
+ class _DummyRoom:
42
+ # Adapter won't touch room when no tools.
43
+ pass
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_live_anthropic_adapter_messages_create_if_key_set():
48
+ api_key = os.getenv("ANTHROPIC_API_KEY")
49
+ if not api_key:
50
+ pytest.skip("ANTHROPIC_API_KEY not set")
51
+
52
+ model = os.getenv("ANTHROPIC_TEST_MODEL", "claude-sonnet-4-5")
53
+
54
+ client = a.AsyncAnthropic(api_key=api_key)
55
+ adapter = AnthropicMessagesAdapter(model=model, client=client, max_tokens=64)
56
+
57
+ ctx = AgentChatContext(system_role=None)
58
+ ctx.append_user_message("Say hello in one word.")
59
+
60
+ text = await adapter.next(context=ctx, room=_DummyRoom(), toolkits=[])
61
+
62
+ assert isinstance(text, str)
63
+ assert len(text.strip()) > 0
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_live_anthropic_adapter_streaming_if_key_set():
68
+ api_key = os.getenv("ANTHROPIC_API_KEY")
69
+ if not api_key:
70
+ pytest.skip("ANTHROPIC_API_KEY not set")
71
+
72
+ model = os.getenv("ANTHROPIC_TEST_MODEL", "claude-sonnet-4-5")
73
+
74
+ client = a.AsyncAnthropic(api_key=api_key)
75
+ adapter = AnthropicMessagesAdapter(model=model, client=client, max_tokens=64)
76
+
77
+ ctx = AgentChatContext(system_role=None)
78
+ ctx.append_user_message("Count from 1 to 3.")
79
+
80
+ seen_types: list[str] = []
81
+
82
+ def handler(event: dict):
83
+ if isinstance(event, dict) and "type" in event:
84
+ seen_types.append(event["type"])
85
+
86
+ text = await adapter.next(
87
+ context=ctx,
88
+ room=_DummyRoom(),
89
+ toolkits=[],
90
+ event_handler=handler,
91
+ )
92
+
93
+ assert isinstance(text, str)
94
+ assert len(text.strip()) > 0
95
+ # These are best-effort; event types depend on Anthropic SDK.
96
+ assert len(seen_types) > 0
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_live_anthropic_mcp_deepwiki_if_key_set():
101
+ api_key = os.getenv("ANTHROPIC_API_KEY")
102
+ if not api_key:
103
+ pytest.skip("ANTHROPIC_API_KEY not set")
104
+
105
+ model = os.getenv("ANTHROPIC_TEST_MODEL", "claude-sonnet-4-5")
106
+
107
+ client = a.AsyncAnthropic(api_key=api_key)
108
+ adapter = AnthropicMessagesAdapter(model=model, client=client, max_tokens=256)
109
+
110
+ ctx = AgentChatContext(system_role=None)
111
+ ctx.append_user_message(
112
+ "Use the DeepWiki MCP toolset and make at least one tool call. "
113
+ "Then reply with a one-sentence summary of what you learned."
114
+ )
115
+
116
+ mcp_toolkit = Toolkit(
117
+ name="mcp",
118
+ tools=[
119
+ MCPTool(
120
+ config=MCPConfig(
121
+ mcp_servers=[
122
+ MCPServer(url="https://mcp.deepwiki.com/mcp", name="deepwiki")
123
+ ]
124
+ )
125
+ )
126
+ ],
127
+ )
128
+
129
+ seen_mcp_blocks = False
130
+
131
+ def handler(event: dict):
132
+ nonlocal seen_mcp_blocks
133
+ if not isinstance(event, dict):
134
+ return
135
+
136
+ # Adapter forwards Anthropic SDK stream events:
137
+ # {"type": "content_block_start", "event": {...}}
138
+ if event.get("type") == "content_block_start":
139
+ payload = event.get("event") or {}
140
+ content_block = payload.get("content_block") or {}
141
+ if content_block.get("type") in {"mcp_tool_use", "mcp_tool_result"}:
142
+ seen_mcp_blocks = True
143
+
144
+ text = await adapter.next(
145
+ context=ctx,
146
+ room=_DummyRoom(),
147
+ toolkits=[mcp_toolkit],
148
+ event_handler=handler,
149
+ )
150
+
151
+ assert isinstance(text, str)
152
+ assert len(text.strip()) > 0
153
+
154
+ # This asserts the connector actually engaged (best-effort, but should be stable
155
+ # for DeepWiki).
156
+ assert seen_mcp_blocks
@@ -0,0 +1,64 @@
1
+ from meshagent.anthropic.mcp import MCPConfig, MCPServer, MCPTool, MCPToolset
2
+
3
+
4
+ def test_mcp_tool_apply_injects_servers_toolsets_and_beta():
5
+ cfg = MCPConfig(
6
+ mcp_servers=[
7
+ MCPServer(url="https://mcp.example.com/sse", name="example-mcp"),
8
+ ],
9
+ toolsets=[MCPToolset(mcp_server_name="example-mcp")],
10
+ )
11
+
12
+ tool = MCPTool(config=cfg)
13
+ request: dict = {"tools": []}
14
+ tool.apply(request=request)
15
+
16
+ assert request["betas"] == ["mcp-client-2025-11-20"]
17
+ assert request["mcp_servers"] == [
18
+ {
19
+ "type": "url",
20
+ "url": "https://mcp.example.com/sse",
21
+ "name": "example-mcp",
22
+ }
23
+ ]
24
+
25
+ assert request["tools"][0]["type"] == "mcp_toolset"
26
+ assert request["tools"][0]["mcp_server_name"] == "example-mcp"
27
+
28
+
29
+ def test_mcp_tool_apply_dedupes_servers_by_name_and_preserves_existing():
30
+ cfg = MCPConfig(
31
+ mcp_servers=[
32
+ MCPServer(url="https://mcp.example.com/sse", name="example-mcp"),
33
+ MCPServer(url="https://mcp.other.com/sse", name="other"),
34
+ ]
35
+ )
36
+ tool = MCPTool(config=cfg)
37
+
38
+ request: dict = {
39
+ "tools": [{"type": "tool", "name": "some_tool"}],
40
+ "mcp_servers": [
41
+ {
42
+ "type": "url",
43
+ "url": "https://old.example.com/sse",
44
+ "name": "example-mcp",
45
+ "authorization_token": "OLD",
46
+ }
47
+ ],
48
+ "betas": ["some-other-beta"],
49
+ }
50
+
51
+ tool.apply(request=request)
52
+
53
+ # Keeps existing betas and appends MCP beta.
54
+ assert "some-other-beta" in request["betas"]
55
+ assert "mcp-client-2025-11-20" in request["betas"]
56
+
57
+ # Dedupes by name; cfg overwrites the existing server entry.
58
+ by_name = {s["name"]: s for s in request["mcp_servers"]}
59
+ assert set(by_name.keys()) == {"example-mcp", "other"}
60
+ assert by_name["example-mcp"]["url"] == "https://mcp.example.com/sse"
61
+
62
+ # If toolsets omitted, it creates one per server.
63
+ mcp_toolsets = [t for t in request["tools"] if t.get("type") == "mcp_toolset"]
64
+ assert {t["mcp_server_name"] for t in mcp_toolsets} == {"example-mcp", "other"}
@@ -0,0 +1,179 @@
1
+ import pytest
2
+
3
+ from meshagent.anthropic.messages_adapter import AnthropicMessagesAdapter
4
+ from meshagent.agents.agent import AgentChatContext
5
+ from meshagent.tools import Tool, Toolkit
6
+ from meshagent.api import RoomException
7
+ from meshagent.agents.adapter import ToolResponseAdapter
8
+
9
+
10
+ class _DummyParticipant:
11
+ def __init__(self):
12
+ self.id = "p1"
13
+
14
+ def get_attribute(self, name: str):
15
+ if name == "name":
16
+ return "tester"
17
+ return None
18
+
19
+
20
+ class _DummyRoom:
21
+ def __init__(self):
22
+ self.local_participant = _DummyParticipant()
23
+
24
+
25
+ class _AnyArgsTool(Tool):
26
+ def __init__(self, name: str):
27
+ super().__init__(
28
+ name=name,
29
+ input_schema={"type": "object", "additionalProperties": True},
30
+ description="test tool",
31
+ )
32
+
33
+ async def execute(self, context, **kwargs):
34
+ return {"ok": True, "args": kwargs}
35
+
36
+
37
+ class _ToolResultAdapter(ToolResponseAdapter):
38
+ async def to_plain_text(self, *, room, response):
39
+ return "ok"
40
+
41
+ async def create_messages(self, *, context, tool_call, room, response):
42
+ return [
43
+ {
44
+ "role": "user",
45
+ "content": [
46
+ {
47
+ "type": "tool_result",
48
+ "tool_use_id": tool_call["id"],
49
+ "content": [{"type": "text", "text": "ok"}],
50
+ }
51
+ ],
52
+ }
53
+ ]
54
+
55
+
56
+ class _FakeAdapter(AnthropicMessagesAdapter):
57
+ def __init__(self, responses: list[dict]):
58
+ super().__init__(client=object())
59
+ self._responses = responses
60
+ self._idx = 0
61
+
62
+ async def _create_with_optional_headers(self, *, client, request):
63
+ if self._idx >= len(self._responses):
64
+ raise AssertionError("unexpected extra request")
65
+ resp = self._responses[self._idx]
66
+ self._idx += 1
67
+ return resp
68
+
69
+
70
+ def test_convert_messages_drops_assistant_between_tool_use_and_tool_result():
71
+ ctx = AgentChatContext(
72
+ system_role=None,
73
+ messages=[
74
+ {"role": "user", "content": "hi"},
75
+ {
76
+ "role": "assistant",
77
+ "content": [
78
+ {"type": "text", "text": "calling tool"},
79
+ {
80
+ "type": "tool_use",
81
+ "id": "toolu_1",
82
+ "name": "tool_a",
83
+ "input": {},
84
+ },
85
+ ],
86
+ },
87
+ {"role": "assistant", "content": "stray assistant message"},
88
+ {
89
+ "role": "user",
90
+ "content": [
91
+ {
92
+ "type": "tool_result",
93
+ "tool_use_id": "toolu_1",
94
+ "content": [{"type": "text", "text": "ok"}],
95
+ }
96
+ ],
97
+ },
98
+ ],
99
+ )
100
+
101
+ adapter = AnthropicMessagesAdapter(client=object())
102
+ msgs, _system = adapter._convert_messages(context=ctx)
103
+
104
+ assert [m["role"] for m in msgs] == ["user", "assistant", "user"]
105
+ assert msgs[1]["content"][1]["type"] == "tool_use"
106
+
107
+
108
+ def test_convert_messages_raises_if_tool_result_not_immediately_next():
109
+ ctx = AgentChatContext(
110
+ system_role=None,
111
+ messages=[
112
+ {"role": "user", "content": "hi"},
113
+ {
114
+ "role": "assistant",
115
+ "content": [
116
+ {
117
+ "type": "tool_use",
118
+ "id": "toolu_1",
119
+ "name": "tool_a",
120
+ "input": {},
121
+ },
122
+ ],
123
+ },
124
+ {"role": "user", "content": "not a tool_result"},
125
+ ],
126
+ )
127
+
128
+ adapter = AnthropicMessagesAdapter(client=object())
129
+
130
+ with pytest.raises(RoomException):
131
+ adapter._convert_messages(context=ctx)
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_next_batches_multiple_tool_results_into_single_user_message():
136
+ responses = [
137
+ {
138
+ "content": [
139
+ {"type": "text", "text": "calling tools"},
140
+ {"type": "tool_use", "id": "toolu_1", "name": "tool_a", "input": {}},
141
+ {"type": "tool_use", "id": "toolu_2", "name": "tool_b", "input": {}},
142
+ ]
143
+ },
144
+ {"content": [{"type": "text", "text": "done"}]},
145
+ ]
146
+
147
+ adapter = _FakeAdapter(responses=responses)
148
+ ctx = AgentChatContext(system_role=None)
149
+ ctx.append_user_message("run tools")
150
+
151
+ toolkit = Toolkit(
152
+ name="test",
153
+ tools=[_AnyArgsTool("tool_a"), _AnyArgsTool("tool_b")],
154
+ )
155
+
156
+ result = await adapter.next(
157
+ context=ctx,
158
+ room=_DummyRoom(),
159
+ toolkits=[toolkit],
160
+ tool_adapter=_ToolResultAdapter(),
161
+ )
162
+
163
+ assert result == "done"
164
+
165
+ # Expect: user -> assistant(tool_use) -> user(tool_results batched) -> assistant(final)
166
+ assert [m["role"] for m in ctx.messages] == [
167
+ "user",
168
+ "assistant",
169
+ "user",
170
+ "assistant",
171
+ ]
172
+
173
+ tool_results_msg = ctx.messages[2]
174
+ assert tool_results_msg["role"] == "user"
175
+ assert len(tool_results_msg["content"]) == 2
176
+ assert {b["tool_use_id"] for b in tool_results_msg["content"]} == {
177
+ "toolu_1",
178
+ "toolu_2",
179
+ }
@@ -0,0 +1,102 @@
1
+ import pytest
2
+ from pydantic import BaseModel
3
+
4
+ from meshagent.anthropic.openai_responses_stream_adapter import (
5
+ AnthropicOpenAIResponsesStreamAdapter,
6
+ )
7
+
8
+
9
+ class _Event(BaseModel):
10
+ type: str
11
+ index: int | None = None
12
+ message: dict | None = None
13
+ content_block: dict | None = None
14
+ delta: dict | None = None
15
+
16
+
17
+ class _FinalMessage(BaseModel):
18
+ id: str = "msg_1"
19
+ usage: dict = {"input_tokens": 3, "output_tokens": 5}
20
+
21
+
22
+ class _FakeStream:
23
+ def __init__(self, events: list[BaseModel], final: BaseModel):
24
+ self._events = events
25
+ self._final = final
26
+
27
+ async def __aenter__(self):
28
+ return self
29
+
30
+ async def __aexit__(self, exc_type, exc, tb):
31
+ return False
32
+
33
+ def __aiter__(self):
34
+ async def gen():
35
+ for e in self._events:
36
+ yield e
37
+
38
+ return gen()
39
+
40
+ async def get_final_message(self):
41
+ return self._final
42
+
43
+
44
+ class _FakeMessages:
45
+ def __init__(self, stream: _FakeStream):
46
+ self._stream = stream
47
+
48
+ def stream(self, **kwargs):
49
+ return self._stream
50
+
51
+
52
+ class _FakeClient:
53
+ def __init__(self, stream: _FakeStream):
54
+ self.messages = _FakeMessages(stream)
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_openai_responses_stream_emits_content_part_events():
59
+ events = [
60
+ _Event(type="message_start", message={"id": "msg_1", "model": "claude"}),
61
+ _Event(type="content_block_start", index=0, content_block={"type": "text"}),
62
+ _Event(
63
+ type="content_block_delta",
64
+ index=0,
65
+ delta={"type": "text_delta", "text": "hi"},
66
+ ),
67
+ _Event(type="content_block_stop", index=0),
68
+ _Event(type="message_stop"),
69
+ ]
70
+
71
+ stream = _FakeStream(events=events, final=_FinalMessage())
72
+ client = _FakeClient(stream)
73
+
74
+ adapter = AnthropicOpenAIResponsesStreamAdapter(client=client)
75
+
76
+ emitted: list[dict] = []
77
+
78
+ def handler(e: dict):
79
+ emitted.append(e)
80
+
81
+ await adapter._stream_message(
82
+ client=client,
83
+ request={"model": "x", "max_tokens": 5, "messages": []},
84
+ event_handler=handler,
85
+ )
86
+
87
+ types = [e["type"] for e in emitted]
88
+
89
+ assert "response.created" in types
90
+ assert "response.output_item.added" in types
91
+ assert "response.content_part.added" in types
92
+ assert "response.output_text.delta" in types
93
+ assert "response.output_text.done" in types
94
+ assert "response.content_part.done" in types
95
+ assert "response.output_item.done" in types
96
+ assert "response.completed" in types
97
+
98
+ # Sanity-check completed response contains usage.
99
+ completed = next(e for e in emitted if e["type"] == "response.completed")
100
+ assert completed["response"]["usage"]["input_tokens"] == 3
101
+ assert completed["response"]["usage"]["output_tokens"] == 5
102
+ assert completed["response"]["usage"]["total_tokens"] == 8
@@ -0,0 +1 @@
1
+ __version__ = "0.22.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-anthropic
3
- Version: 0.21.0
3
+ Version: 0.22.1
4
4
  Summary: Anthropic Building Blocks for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -11,9 +11,9 @@ Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: pytest~=8.4
13
13
  Requires-Dist: pytest-asyncio~=0.26
14
- Requires-Dist: meshagent-api~=0.21.0
15
- Requires-Dist: meshagent-agents~=0.21.0
16
- Requires-Dist: meshagent-tools~=0.21.0
14
+ Requires-Dist: meshagent-api~=0.22.1
15
+ Requires-Dist: meshagent-agents~=0.22.1
16
+ Requires-Dist: meshagent-tools~=0.22.1
17
17
  Requires-Dist: anthropic<1.0,>=0.25
18
18
  Dynamic: license-file
19
19
 
@@ -0,0 +1,21 @@
1
+ CHANGELOG.md
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ pyproject.toml
6
+ meshagent/anthropic/__init__.py
7
+ meshagent/anthropic/mcp.py
8
+ meshagent/anthropic/messages_adapter.py
9
+ meshagent/anthropic/openai_responses_stream_adapter.py
10
+ meshagent/anthropic/version.py
11
+ meshagent/anthropic/proxy/__init__.py
12
+ meshagent/anthropic/proxy/proxy.py
13
+ meshagent/anthropic/tests/anthropic_live_test.py
14
+ meshagent/anthropic/tests/mcp_test.py
15
+ meshagent/anthropic/tests/messages_adapter_test.py
16
+ meshagent/anthropic/tests/openai_responses_stream_adapter_test.py
17
+ meshagent_anthropic.egg-info/PKG-INFO
18
+ meshagent_anthropic.egg-info/SOURCES.txt
19
+ meshagent_anthropic.egg-info/dependency_links.txt
20
+ meshagent_anthropic.egg-info/requires.txt
21
+ meshagent_anthropic.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ pytest~=8.4
2
+ pytest-asyncio~=0.26
3
+ meshagent-api~=0.22.1
4
+ meshagent-agents~=0.22.1
5
+ meshagent-tools~=0.22.1
6
+ anthropic<1.0,>=0.25
@@ -12,9 +12,9 @@ keywords = []
12
12
  dependencies = [
13
13
  "pytest~=8.4",
14
14
  "pytest-asyncio~=0.26",
15
- "meshagent-api~=0.21.0",
16
- "meshagent-agents~=0.21.0",
17
- "meshagent-tools~=0.21.0",
15
+ "meshagent-api~=0.22.1",
16
+ "meshagent-agents~=0.22.1",
17
+ "meshagent-tools~=0.22.1",
18
18
  "anthropic>=0.25,<1.0"
19
19
  ]
20
20
 
@@ -1,13 +0,0 @@
1
- from .tools import (
2
- AnthropicMessagesAdapter,
3
- AnthropicMessagesToolResponseAdapter,
4
- AnthropicOpenAIResponsesStreamAdapter,
5
- )
6
- from .version import __version__
7
-
8
- __all__ = [
9
- __version__,
10
- AnthropicMessagesAdapter,
11
- AnthropicMessagesToolResponseAdapter,
12
- AnthropicOpenAIResponsesStreamAdapter,
13
- ]
@@ -1 +0,0 @@
1
- __version__ = "0.21.0"
@@ -1,17 +0,0 @@
1
- CHANGELOG.md
2
- LICENSE
3
- MANIFEST.in
4
- README.md
5
- pyproject.toml
6
- meshagent/anthropic/__init__.py
7
- meshagent/anthropic/version.py
8
- meshagent/anthropic/proxy/__init__.py
9
- meshagent/anthropic/proxy/proxy.py
10
- meshagent/anthropic/tools/__init__.py
11
- meshagent/anthropic/tools/messages_adapter.py
12
- meshagent/anthropic/tools/openai_responses_stream_adapter.py
13
- meshagent_anthropic.egg-info/PKG-INFO
14
- meshagent_anthropic.egg-info/SOURCES.txt
15
- meshagent_anthropic.egg-info/dependency_links.txt
16
- meshagent_anthropic.egg-info/requires.txt
17
- meshagent_anthropic.egg-info/top_level.txt
@@ -1,6 +0,0 @@
1
- pytest~=8.4
2
- pytest-asyncio~=0.26
3
- meshagent-api~=0.21.0
4
- meshagent-agents~=0.21.0
5
- meshagent-tools~=0.21.0
6
- anthropic<1.0,>=0.25