letta-nightly 0.7.30.dev20250603104343__py3-none-any.whl → 0.8.0.dev20250604104349__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 (136) hide show
  1. letta/__init__.py +7 -1
  2. letta/agent.py +14 -7
  3. letta/agents/base_agent.py +1 -0
  4. letta/agents/ephemeral_summary_agent.py +104 -0
  5. letta/agents/helpers.py +35 -3
  6. letta/agents/letta_agent.py +492 -176
  7. letta/agents/letta_agent_batch.py +22 -16
  8. letta/agents/prompts/summary_system_prompt.txt +62 -0
  9. letta/agents/voice_agent.py +22 -7
  10. letta/agents/voice_sleeptime_agent.py +13 -8
  11. letta/constants.py +33 -1
  12. letta/data_sources/connectors.py +52 -36
  13. letta/errors.py +4 -0
  14. letta/functions/ast_parsers.py +13 -30
  15. letta/functions/function_sets/base.py +3 -1
  16. letta/functions/functions.py +2 -0
  17. letta/functions/mcp_client/base_client.py +151 -97
  18. letta/functions/mcp_client/sse_client.py +49 -31
  19. letta/functions/mcp_client/stdio_client.py +107 -106
  20. letta/functions/schema_generator.py +22 -22
  21. letta/groups/helpers.py +3 -4
  22. letta/groups/sleeptime_multi_agent.py +4 -4
  23. letta/groups/sleeptime_multi_agent_v2.py +22 -0
  24. letta/helpers/composio_helpers.py +16 -0
  25. letta/helpers/converters.py +20 -0
  26. letta/helpers/datetime_helpers.py +1 -6
  27. letta/helpers/tool_rule_solver.py +2 -1
  28. letta/interfaces/anthropic_streaming_interface.py +17 -2
  29. letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
  30. letta/interfaces/openai_streaming_interface.py +18 -2
  31. letta/llm_api/anthropic_client.py +24 -3
  32. letta/llm_api/google_ai_client.py +0 -15
  33. letta/llm_api/google_vertex_client.py +6 -5
  34. letta/llm_api/llm_client_base.py +15 -0
  35. letta/llm_api/openai.py +2 -2
  36. letta/llm_api/openai_client.py +60 -8
  37. letta/orm/__init__.py +2 -0
  38. letta/orm/agent.py +45 -43
  39. letta/orm/base.py +0 -2
  40. letta/orm/block.py +1 -0
  41. letta/orm/custom_columns.py +13 -0
  42. letta/orm/enums.py +5 -0
  43. letta/orm/file.py +3 -1
  44. letta/orm/files_agents.py +68 -0
  45. letta/orm/mcp_server.py +48 -0
  46. letta/orm/message.py +1 -0
  47. letta/orm/organization.py +11 -2
  48. letta/orm/passage.py +25 -10
  49. letta/orm/sandbox_config.py +5 -2
  50. letta/orm/sqlalchemy_base.py +171 -110
  51. letta/prompts/system/memgpt_base.txt +6 -1
  52. letta/prompts/system/memgpt_v2_chat.txt +57 -0
  53. letta/prompts/system/sleeptime.txt +2 -0
  54. letta/prompts/system/sleeptime_v2.txt +28 -0
  55. letta/schemas/agent.py +87 -20
  56. letta/schemas/block.py +7 -1
  57. letta/schemas/file.py +57 -0
  58. letta/schemas/mcp.py +74 -0
  59. letta/schemas/memory.py +5 -2
  60. letta/schemas/message.py +9 -0
  61. letta/schemas/openai/openai.py +0 -6
  62. letta/schemas/providers.py +33 -4
  63. letta/schemas/tool.py +26 -21
  64. letta/schemas/tool_execution_result.py +5 -0
  65. letta/server/db.py +23 -8
  66. letta/server/rest_api/app.py +73 -56
  67. letta/server/rest_api/interface.py +4 -4
  68. letta/server/rest_api/routers/v1/agents.py +132 -47
  69. letta/server/rest_api/routers/v1/blocks.py +3 -2
  70. letta/server/rest_api/routers/v1/embeddings.py +3 -3
  71. letta/server/rest_api/routers/v1/groups.py +3 -3
  72. letta/server/rest_api/routers/v1/jobs.py +14 -17
  73. letta/server/rest_api/routers/v1/organizations.py +10 -10
  74. letta/server/rest_api/routers/v1/providers.py +12 -10
  75. letta/server/rest_api/routers/v1/runs.py +3 -3
  76. letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
  77. letta/server/rest_api/routers/v1/sources.py +108 -43
  78. letta/server/rest_api/routers/v1/steps.py +8 -6
  79. letta/server/rest_api/routers/v1/tools.py +134 -95
  80. letta/server/rest_api/utils.py +12 -1
  81. letta/server/server.py +272 -73
  82. letta/services/agent_manager.py +246 -313
  83. letta/services/block_manager.py +30 -9
  84. letta/services/context_window_calculator/__init__.py +0 -0
  85. letta/services/context_window_calculator/context_window_calculator.py +150 -0
  86. letta/services/context_window_calculator/token_counter.py +82 -0
  87. letta/services/file_processor/__init__.py +0 -0
  88. letta/services/file_processor/chunker/__init__.py +0 -0
  89. letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
  90. letta/services/file_processor/embedder/__init__.py +0 -0
  91. letta/services/file_processor/embedder/openai_embedder.py +84 -0
  92. letta/services/file_processor/file_processor.py +123 -0
  93. letta/services/file_processor/parser/__init__.py +0 -0
  94. letta/services/file_processor/parser/base_parser.py +9 -0
  95. letta/services/file_processor/parser/mistral_parser.py +54 -0
  96. letta/services/file_processor/types.py +0 -0
  97. letta/services/files_agents_manager.py +184 -0
  98. letta/services/group_manager.py +118 -0
  99. letta/services/helpers/agent_manager_helper.py +76 -21
  100. letta/services/helpers/tool_execution_helper.py +3 -0
  101. letta/services/helpers/tool_parser_helper.py +100 -0
  102. letta/services/identity_manager.py +44 -42
  103. letta/services/job_manager.py +21 -10
  104. letta/services/mcp/base_client.py +5 -2
  105. letta/services/mcp/sse_client.py +3 -5
  106. letta/services/mcp/stdio_client.py +3 -5
  107. letta/services/mcp_manager.py +281 -0
  108. letta/services/message_manager.py +40 -26
  109. letta/services/organization_manager.py +55 -19
  110. letta/services/passage_manager.py +211 -13
  111. letta/services/provider_manager.py +48 -2
  112. letta/services/sandbox_config_manager.py +105 -0
  113. letta/services/source_manager.py +4 -5
  114. letta/services/step_manager.py +9 -6
  115. letta/services/summarizer/summarizer.py +50 -23
  116. letta/services/telemetry_manager.py +7 -0
  117. letta/services/tool_executor/tool_execution_manager.py +11 -52
  118. letta/services/tool_executor/tool_execution_sandbox.py +4 -34
  119. letta/services/tool_executor/tool_executor.py +107 -105
  120. letta/services/tool_manager.py +56 -17
  121. letta/services/tool_sandbox/base.py +39 -92
  122. letta/services/tool_sandbox/e2b_sandbox.py +16 -11
  123. letta/services/tool_sandbox/local_sandbox.py +51 -23
  124. letta/services/user_manager.py +36 -3
  125. letta/settings.py +10 -3
  126. letta/templates/__init__.py +0 -0
  127. letta/templates/sandbox_code_file.py.j2 +47 -0
  128. letta/templates/template_helper.py +16 -0
  129. letta/tracing.py +30 -1
  130. letta/types/__init__.py +7 -0
  131. letta/utils.py +25 -1
  132. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/METADATA +7 -2
  133. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/RECORD +136 -110
  134. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/LICENSE +0 -0
  135. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/WHEEL +0 -0
  136. {letta_nightly-0.7.30.dev20250603104343.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/entry_points.txt +0 -0
@@ -3,7 +3,6 @@ from typing import List, Optional
3
3
  from fastapi import HTTPException
4
4
  from sqlalchemy import select
5
5
  from sqlalchemy.exc import NoResultFound
6
- from sqlalchemy.orm import Session
7
6
 
8
7
  from letta.orm.agent import Agent as AgentModel
9
8
  from letta.orm.block import Block as BlockModel
@@ -60,26 +59,29 @@ class IdentityManager:
60
59
  @trace_method
61
60
  async def create_identity_async(self, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
62
61
  async with db_registry.async_session() as session:
63
- new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids", "block_ids"}, exclude_unset=True))
64
- new_identity.organization_id = actor.organization_id
65
- await self._process_relationship_async(
66
- session=session,
67
- identity=new_identity,
68
- relationship_name="agents",
69
- model_class=AgentModel,
70
- item_ids=identity.agent_ids,
71
- allow_partial=False,
72
- )
73
- await self._process_relationship_async(
74
- session=session,
75
- identity=new_identity,
76
- relationship_name="blocks",
77
- model_class=BlockModel,
78
- item_ids=identity.block_ids,
79
- allow_partial=False,
80
- )
81
- await new_identity.create_async(session, actor=actor)
82
- return new_identity.to_pydantic()
62
+ return await self._create_identity_async(db_session=session, identity=identity, actor=actor)
63
+
64
+ async def _create_identity_async(self, db_session, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
65
+ new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids", "block_ids"}, exclude_unset=True))
66
+ new_identity.organization_id = actor.organization_id
67
+ await self._process_relationship_async(
68
+ db_session=db_session,
69
+ identity=new_identity,
70
+ relationship_name="agents",
71
+ model_class=AgentModel,
72
+ item_ids=identity.agent_ids,
73
+ allow_partial=False,
74
+ )
75
+ await self._process_relationship_async(
76
+ db_session=db_session,
77
+ identity=new_identity,
78
+ relationship_name="blocks",
79
+ model_class=BlockModel,
80
+ item_ids=identity.block_ids,
81
+ allow_partial=False,
82
+ )
83
+ await new_identity.create_async(db_session=db_session, actor=actor)
84
+ return new_identity.to_pydantic()
83
85
 
84
86
  @enforce_types
85
87
  @trace_method
@@ -93,19 +95,19 @@ class IdentityManager:
93
95
  actor=actor,
94
96
  )
95
97
 
96
- if existing_identity is None:
97
- return await self.create_identity_async(identity=IdentityCreate(**identity.model_dump()), actor=actor)
98
- else:
99
- identity_update = IdentityUpdate(
100
- name=identity.name,
101
- identifier_key=identity.identifier_key,
102
- identity_type=identity.identity_type,
103
- agent_ids=identity.agent_ids,
104
- properties=identity.properties,
105
- )
106
- return await self._update_identity_async(
107
- session=session, existing_identity=existing_identity, identity=identity_update, actor=actor, replace=True
108
- )
98
+ if existing_identity is None:
99
+ return await self._create_identity_async(db_session=session, identity=IdentityCreate(**identity.model_dump()), actor=actor)
100
+ else:
101
+ identity_update = IdentityUpdate(
102
+ name=identity.name,
103
+ identifier_key=identity.identifier_key,
104
+ identity_type=identity.identity_type,
105
+ agent_ids=identity.agent_ids,
106
+ properties=identity.properties,
107
+ )
108
+ return await self._update_identity_async(
109
+ db_session=session, existing_identity=existing_identity, identity=identity_update, actor=actor, replace=True
110
+ )
109
111
 
110
112
  @enforce_types
111
113
  @trace_method
@@ -121,12 +123,12 @@ class IdentityManager:
121
123
  raise HTTPException(status_code=403, detail="Forbidden")
122
124
 
123
125
  return await self._update_identity_async(
124
- session=session, existing_identity=existing_identity, identity=identity, actor=actor, replace=replace
126
+ db_session=session, existing_identity=existing_identity, identity=identity, actor=actor, replace=replace
125
127
  )
126
128
 
127
129
  async def _update_identity_async(
128
130
  self,
129
- session: Session,
131
+ db_session,
130
132
  existing_identity: IdentityModel,
131
133
  identity: IdentityUpdate,
132
134
  actor: PydanticUser,
@@ -149,7 +151,7 @@ class IdentityManager:
149
151
 
150
152
  if identity.agent_ids is not None:
151
153
  await self._process_relationship_async(
152
- session=session,
154
+ db_session=db_session,
153
155
  identity=existing_identity,
154
156
  relationship_name="agents",
155
157
  model_class=AgentModel,
@@ -159,7 +161,7 @@ class IdentityManager:
159
161
  )
160
162
  if identity.block_ids is not None:
161
163
  await self._process_relationship_async(
162
- session=session,
164
+ db_session=db_session,
163
165
  identity=existing_identity,
164
166
  relationship_name="blocks",
165
167
  model_class=BlockModel,
@@ -167,7 +169,7 @@ class IdentityManager:
167
169
  allow_partial=False,
168
170
  replace=replace,
169
171
  )
170
- await existing_identity.update_async(session, actor=actor)
172
+ await existing_identity.update_async(db_session=db_session, actor=actor)
171
173
  return existing_identity.to_pydantic()
172
174
 
173
175
  @enforce_types
@@ -180,7 +182,7 @@ class IdentityManager:
180
182
  if existing_identity is None:
181
183
  raise HTTPException(status_code=404, detail="Identity not found")
182
184
  return await self._update_identity_async(
183
- session=session,
185
+ db_session=session,
184
186
  existing_identity=existing_identity,
185
187
  identity=IdentityUpdate(properties=properties),
186
188
  actor=actor,
@@ -213,7 +215,7 @@ class IdentityManager:
213
215
 
214
216
  async def _process_relationship_async(
215
217
  self,
216
- session: Session,
218
+ db_session,
217
219
  identity: PydanticIdentity,
218
220
  relationship_name: str,
219
221
  model_class,
@@ -228,7 +230,7 @@ class IdentityManager:
228
230
  return
229
231
 
230
232
  # Retrieve models for the provided IDs
231
- found_items = (await session.execute(select(model_class).where(model_class.id.in_(item_ids)))).scalars().all()
233
+ found_items = (await db_session.execute(select(model_class).where(model_class.id.in_(item_ids)))).scalars().all()
232
234
 
233
235
  # Validate all items are found if allow_partial is False
234
236
  if not allow_partial and len(found_items) != len(item_ids):
@@ -169,6 +169,7 @@ class JobManager:
169
169
  statuses: Optional[List[JobStatus]] = None,
170
170
  job_type: JobType = JobType.JOB,
171
171
  ascending: bool = True,
172
+ source_id: Optional[str] = None,
172
173
  ) -> List[PydanticJob]:
173
174
  """List all jobs with optional pagination and status filter."""
174
175
  async with db_registry.async_session() as session:
@@ -178,6 +179,9 @@ class JobManager:
178
179
  if statuses:
179
180
  filter_kwargs["status"] = statuses
180
181
 
182
+ if source_id:
183
+ filter_kwargs["metadata_.source_id"] = source_id
184
+
181
185
  jobs = await JobModel.list_async(
182
186
  db_session=session,
183
187
  before=before,
@@ -197,6 +201,15 @@ class JobManager:
197
201
  job.hard_delete(db_session=session, actor=actor)
198
202
  return job.to_pydantic()
199
203
 
204
+ @enforce_types
205
+ @trace_method
206
+ async def delete_job_by_id_async(self, job_id: str, actor: PydanticUser) -> PydanticJob:
207
+ """Delete a job by its ID."""
208
+ async with db_registry.async_session() as session:
209
+ job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor)
210
+ await job.hard_delete_async(db_session=session, actor=actor)
211
+ return job.to_pydantic()
212
+
200
213
  @enforce_types
201
214
  @trace_method
202
215
  def get_job_messages(
@@ -599,25 +612,23 @@ class JobManager:
599
612
 
600
613
  async def _dispatch_callback_async(self, session, job: JobModel) -> None:
601
614
  """
602
- POST a standard JSON payload to job.callback_url
603
- and record timestamp + HTTP status asynchronously.
615
+ POST a standard JSON payload to job.callback_url and record timestamp + HTTP status asynchronously.
604
616
  """
605
-
606
617
  payload = {
607
618
  "job_id": job.id,
608
619
  "status": job.status,
609
- "completed_at": job.completed_at.isoformat(),
620
+ "completed_at": job.completed_at.isoformat() if job.completed_at else None,
610
621
  }
622
+
611
623
  try:
612
624
  import httpx
613
625
 
614
626
  async with httpx.AsyncClient() as client:
615
627
  resp = await client.post(job.callback_url, json=payload, timeout=5.0)
616
- job.callback_sent_at = get_utc_time()
628
+ # Ensure timestamp is timezone-naive for DB compatibility
629
+ job.callback_sent_at = get_utc_time().replace(tzinfo=None)
617
630
  job.callback_status_code = resp.status_code
618
-
619
631
  except Exception:
620
- return
621
-
622
- session.add(job)
623
- await session.commit()
632
+ # Silently fail on callback errors - job updates should still succeed
633
+ # In production, this would include proper error logging
634
+ pass
@@ -30,7 +30,7 @@ class AsyncBaseMCPClient:
30
30
  )
31
31
  raise e
32
32
 
33
- async def _initialize_connection(self, exit_stack: AsyncExitStack[bool | None], server_config: BaseServerConfig) -> None:
33
+ async def _initialize_connection(self, server_config: BaseServerConfig) -> None:
34
34
  raise NotImplementedError("Subclasses must implement _initialize_connection")
35
35
 
36
36
  async def list_tools(self) -> list[MCPTool]:
@@ -55,7 +55,7 @@ class AsyncBaseMCPClient:
55
55
  # TODO move hardcoding to constants
56
56
  final_content = "Empty response from tool"
57
57
 
58
- return final_content, result.isError
58
+ return final_content, not result.isError
59
59
 
60
60
  def _check_initialized(self):
61
61
  if not self.initialized:
@@ -65,3 +65,6 @@ class AsyncBaseMCPClient:
65
65
  async def cleanup(self):
66
66
  """Clean up resources"""
67
67
  await self.exit_stack.aclose()
68
+
69
+ def to_sync_client(self):
70
+ raise NotImplementedError("Subclasses must implement to_sync_client")
@@ -1,5 +1,3 @@
1
- from contextlib import AsyncExitStack
2
-
3
1
  from mcp import ClientSession
4
2
  from mcp.client.sse import sse_client
5
3
 
@@ -15,11 +13,11 @@ logger = get_logger(__name__)
15
13
 
16
14
  # TODO: Get rid of Async prefix on this class name once we deprecate old sync code
17
15
  class AsyncSSEMCPClient(AsyncBaseMCPClient):
18
- async def _initialize_connection(self, exit_stack: AsyncExitStack[bool | None], server_config: SSEServerConfig) -> None:
16
+ async def _initialize_connection(self, server_config: SSEServerConfig) -> None:
19
17
  sse_cm = sse_client(url=server_config.server_url)
20
- sse_transport = await exit_stack.enter_async_context(sse_cm)
18
+ sse_transport = await self.exit_stack.enter_async_context(sse_cm)
21
19
  self.stdio, self.write = sse_transport
22
20
 
23
21
  # Create and enter the ClientSession context manager
24
22
  session_cm = ClientSession(self.stdio, self.write)
25
- self.session = await exit_stack.enter_async_context(session_cm)
23
+ self.session = await self.exit_stack.enter_async_context(session_cm)
@@ -1,5 +1,3 @@
1
- from contextlib import AsyncExitStack
2
-
3
1
  from mcp import ClientSession, StdioServerParameters
4
2
  from mcp.client.stdio import stdio_client
5
3
 
@@ -12,8 +10,8 @@ logger = get_logger(__name__)
12
10
 
13
11
  # TODO: Get rid of Async prefix on this class name once we deprecate old sync code
14
12
  class AsyncStdioMCPClient(AsyncBaseMCPClient):
15
- async def _initialize_connection(self, exit_stack: AsyncExitStack[bool | None], server_config: StdioServerConfig) -> None:
13
+ async def _initialize_connection(self, server_config: StdioServerConfig) -> None:
16
14
  server_params = StdioServerParameters(command=server_config.command, args=server_config.args)
17
- stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params))
15
+ stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
18
16
  self.stdio, self.write = stdio_transport
19
- self.session = await exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
17
+ self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
@@ -0,0 +1,281 @@
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, List, Optional, Tuple, Union
4
+
5
+ import letta.constants as constants
6
+ from letta.functions.mcp_client.types import MCPServerType, MCPTool, SSEServerConfig, StdioServerConfig
7
+ from letta.log import get_logger
8
+ from letta.orm.errors import NoResultFound
9
+ from letta.orm.mcp_server import MCPServer as MCPServerModel
10
+ from letta.schemas.mcp import MCPServer, UpdateMCPServer, UpdateSSEMCPServer, UpdateStdioMCPServer
11
+ from letta.schemas.tool import Tool as PydanticTool
12
+ from letta.schemas.tool import ToolCreate
13
+ from letta.schemas.user import User as PydanticUser
14
+ from letta.server.db import db_registry
15
+ from letta.services.mcp.sse_client import MCP_CONFIG_TOPLEVEL_KEY, AsyncSSEMCPClient
16
+ from letta.services.mcp.stdio_client import AsyncStdioMCPClient
17
+ from letta.services.tool_manager import ToolManager
18
+ from letta.utils import enforce_types, printd
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ class MCPManager:
24
+ """Manager class to handle business logic related to MCP."""
25
+
26
+ def __init__(self):
27
+ # TODO: timeouts?
28
+ self.tool_manager = ToolManager()
29
+ self.cached_mcp_servers = {} # maps id -> async connection
30
+
31
+ @enforce_types
32
+ async def list_mcp_server_tools(self, mcp_server_name: str, actor: PydanticUser) -> List[MCPTool]:
33
+ """Get a list of all tools for a specific MCP server."""
34
+ print("mcp_server_name", mcp_server_name)
35
+ mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor=actor)
36
+ mcp_config = await self.get_mcp_server_by_id_async(mcp_server_id, actor=actor)
37
+ server_config = mcp_config.to_config()
38
+
39
+ if mcp_config.server_type == MCPServerType.SSE:
40
+ mcp_client = AsyncSSEMCPClient(server_config=server_config)
41
+ elif mcp_config.server_type == MCPServerType.STDIO:
42
+ mcp_client = AsyncStdioMCPClient(server_config=server_config)
43
+ await mcp_client.connect_to_server()
44
+
45
+ # list tools
46
+ tools = await mcp_client.list_tools()
47
+ # TODO: change to pydantic tools
48
+
49
+ await mcp_client.cleanup()
50
+
51
+ return tools
52
+
53
+ @enforce_types
54
+ async def execute_mcp_server_tool(
55
+ self, mcp_server_name: str, tool_name: str, tool_args: Optional[Dict[str, Any]], actor: PydanticUser
56
+ ) -> Tuple[str, bool]:
57
+ """Call a specific tool from a specific MCP server."""
58
+
59
+ from letta.settings import tool_settings
60
+
61
+ if not tool_settings.mcp_read_from_config:
62
+ # read from DB
63
+ mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor=actor)
64
+ mcp_config = await self.get_mcp_server_by_id_async(mcp_server_id, actor=actor)
65
+ server_config = mcp_config.to_config()
66
+ else:
67
+ # read from config file
68
+ mcp_config = self.read_mcp_config()
69
+ if mcp_server_name not in mcp_config:
70
+ print("MCP server not found in config.", mcp_config)
71
+ raise ValueError(f"MCP server {mcp_server_name} not found in config.")
72
+ server_config = mcp_config[mcp_server_name]
73
+
74
+ if isinstance(server_config, SSEServerConfig):
75
+ mcp_client = AsyncSSEMCPClient(server_config=server_config)
76
+ elif isinstance(server_config, StdioServerConfig):
77
+ mcp_client = AsyncStdioMCPClient(server_config=server_config)
78
+ await mcp_client.connect_to_server()
79
+
80
+ # call tool
81
+ result, success = await mcp_client.execute_tool(tool_name, tool_args)
82
+ logger.info(f"MCP Result: {result}, Success: {success}")
83
+ # TODO: change to pydantic tool
84
+
85
+ await mcp_client.cleanup()
86
+
87
+ return result, success
88
+
89
+ @enforce_types
90
+ async def add_tool_from_mcp_server(self, mcp_server_name: str, mcp_tool_name: str, actor: PydanticUser) -> PydanticTool:
91
+ """Add a tool from an MCP server to the Letta tool registry."""
92
+ mcp_tools = await self.list_mcp_server_tools(mcp_server_name, actor=actor)
93
+
94
+ for mcp_tool in mcp_tools:
95
+ if mcp_tool.name == mcp_tool_name:
96
+ tool_create = ToolCreate.from_mcp(mcp_server_name=mcp_server_name, mcp_tool=mcp_tool)
97
+ return await self.tool_manager.create_mcp_tool_async(tool_create=tool_create, mcp_server_name=mcp_server_name, actor=actor)
98
+
99
+ # failed to add - handle error?
100
+ return None
101
+
102
+ @enforce_types
103
+ async def list_mcp_servers(self, actor: PydanticUser) -> List[MCPServer]:
104
+ """List all MCP servers available"""
105
+ async with db_registry.async_session() as session:
106
+ mcp_servers = await MCPServerModel.list_async(
107
+ db_session=session,
108
+ organization_id=actor.organization_id,
109
+ )
110
+
111
+ return [mcp_server.to_pydantic() for mcp_server in mcp_servers]
112
+
113
+ @enforce_types
114
+ async def create_or_update_mcp_server(self, pydantic_mcp_server: MCPServer, actor: PydanticUser) -> MCPServer:
115
+ """Create a new tool based on the ToolCreate schema."""
116
+ mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name=pydantic_mcp_server.server_name, actor=actor)
117
+ print("FOUND SERVER", mcp_server_id, pydantic_mcp_server.server_name)
118
+ if mcp_server_id:
119
+ # Put to dict and remove fields that should not be reset
120
+ update_data = pydantic_mcp_server.model_dump(exclude_unset=True, exclude_none=True)
121
+
122
+ # If there's anything to update (can only update the configs, not the name)
123
+ if update_data:
124
+ if pydantic_mcp_server.server_type == MCPServerType.SSE:
125
+ update_request = UpdateSSEMCPServer(server_url=pydantic_mcp_server.server_url)
126
+ elif pydantic_mcp_server.server_type == MCPServerType.STDIO:
127
+ update_request = UpdateStdioMCPServer(stdio_config=pydantic_mcp_server.stdio_config)
128
+ mcp_server = await self.update_mcp_server_by_id(mcp_server_id, update_request, actor)
129
+ print("RETURN", mcp_server)
130
+ else:
131
+ printd(
132
+ f"`create_or_update_mcp_server` was called with user_id={actor.id}, organization_id={actor.organization_id}, name={pydantic_mcp_server.server_name}, but found existing mcp server with nothing to update."
133
+ )
134
+ mcp_server = await self.get_mcp_server_by_id_async(mcp_server_id, actor=actor)
135
+ else:
136
+ mcp_server = await self.create_mcp_server(pydantic_mcp_server, actor=actor)
137
+
138
+ return mcp_server
139
+
140
+ @enforce_types
141
+ async def create_mcp_server(self, pydantic_mcp_server: MCPServer, actor: PydanticUser) -> PydanticTool:
142
+ """Create a new tool based on the ToolCreate schema."""
143
+ with db_registry.session() as session:
144
+ # Set the organization id at the ORM layer
145
+ pydantic_mcp_server.organization_id = actor.organization_id
146
+ mcp_server_data = pydantic_mcp_server.model_dump(to_orm=True)
147
+
148
+ mcp_server = MCPServerModel(**mcp_server_data)
149
+ mcp_server.create(session, actor=actor) # Re-raise other database-related errors
150
+ return mcp_server.to_pydantic()
151
+
152
+ @enforce_types
153
+ async def update_mcp_server_by_id(self, mcp_server_id: str, mcp_server_update: UpdateMCPServer, actor: PydanticUser) -> PydanticTool:
154
+ """Update a tool by its ID with the given ToolUpdate object."""
155
+ async with db_registry.async_session() as session:
156
+ # Fetch the tool by ID
157
+ mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
158
+
159
+ # Update tool attributes with only the fields that were explicitly set
160
+ update_data = mcp_server_update.model_dump(to_orm=True, exclude_none=True)
161
+ for key, value in update_data.items():
162
+ setattr(mcp_server, key, value)
163
+
164
+ mcp_server = await mcp_server.update_async(db_session=session, actor=actor)
165
+
166
+ # Save the updated tool to the database mcp_server = await mcp_server.update_async(db_session=session, actor=actor)
167
+ return mcp_server.to_pydantic()
168
+
169
+ @enforce_types
170
+ async def get_mcp_server_id_by_name(self, mcp_server_name: str, actor: PydanticUser) -> Optional[str]:
171
+ """Retrieve a MCP server by its name and a user"""
172
+ try:
173
+ async with db_registry.async_session() as session:
174
+ mcp_server = await MCPServerModel.read_async(db_session=session, server_name=mcp_server_name, actor=actor)
175
+ return mcp_server.id
176
+ except NoResultFound:
177
+ return None
178
+
179
+ @enforce_types
180
+ async def get_mcp_server_by_id_async(self, mcp_server_id: str, actor: PydanticUser) -> MCPServer:
181
+ """Fetch a tool by its ID."""
182
+ async with db_registry.async_session() as session:
183
+ # Retrieve tool by id using the Tool model's read method
184
+ mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
185
+ # Convert the SQLAlchemy Tool object to PydanticTool
186
+ return mcp_server.to_pydantic()
187
+
188
+ @enforce_types
189
+ async def get_mcp_server(self, mcp_server_name: str, actor: PydanticUser) -> PydanticTool:
190
+ """Get a tool by name."""
191
+ async with db_registry.async_session() as session:
192
+ mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor)
193
+ mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
194
+ if not mcp_server:
195
+ raise HTTPException(
196
+ status_code=404, # Not Found
197
+ detail={
198
+ "code": "MCPServerNotFoundError",
199
+ "message": f"MCP server {mcp_server_name} not found",
200
+ "mcp_server_name": mcp_server_name,
201
+ },
202
+ )
203
+ return mcp_server.to_pydantic()
204
+
205
+ # @enforce_types
206
+ # async def delete_mcp_server(self, mcp_server_name: str, actor: PydanticUser) -> None:
207
+ # """Delete an existing tool."""
208
+ # with db_registry.session() as session:
209
+ # mcp_server_id = await self.get_mcp_server_id_by_name(mcp_server_name, actor)
210
+ # mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
211
+ # if not mcp_server:
212
+ # raise HTTPException(
213
+ # status_code=404, # Not Found
214
+ # detail={
215
+ # "code": "MCPServerNotFoundError",
216
+ # "message": f"MCP server {mcp_server_name} not found",
217
+ # "mcp_server_name": mcp_server_name,
218
+ # },
219
+ # )
220
+ # mcp_server.delete(session, actor=actor) # Re-raise other database-related errors
221
+
222
+ @enforce_types
223
+ def delete_mcp_server_by_id(self, mcp_server_id: str, actor: PydanticUser) -> None:
224
+ """Delete a tool by its ID."""
225
+ with db_registry.session() as session:
226
+ try:
227
+ mcp_server = MCPServerModel.read(db_session=session, identifier=mcp_server_id, actor=actor)
228
+ mcp_server.hard_delete(db_session=session, actor=actor)
229
+ except NoResultFound:
230
+ raise ValueError(f"MCP server with id {mcp_server_id} not found.")
231
+
232
+ def read_mcp_config(self) -> dict[str, Union[SSEServerConfig, StdioServerConfig]]:
233
+ mcp_server_list = {}
234
+
235
+ # Attempt to read from ~/.letta/mcp_config.json
236
+ mcp_config_path = os.path.join(constants.LETTA_DIR, constants.MCP_CONFIG_NAME)
237
+ if os.path.exists(mcp_config_path):
238
+ with open(mcp_config_path, "r") as f:
239
+
240
+ try:
241
+ mcp_config = json.load(f)
242
+ except Exception as e:
243
+ logger.error(f"Failed to parse MCP config file ({mcp_config_path}) as json: {e}")
244
+ return mcp_server_list
245
+
246
+ # Proper formatting is "mcpServers" key at the top level,
247
+ # then a dict with the MCP server name as the key,
248
+ # with the value being the schema from StdioServerParameters
249
+ if MCP_CONFIG_TOPLEVEL_KEY in mcp_config:
250
+ for server_name, server_params_raw in mcp_config[MCP_CONFIG_TOPLEVEL_KEY].items():
251
+
252
+ # No support for duplicate server names
253
+ if server_name in mcp_server_list:
254
+ logger.error(f"Duplicate MCP server name found (skipping): {server_name}")
255
+ continue
256
+
257
+ if "url" in server_params_raw:
258
+ # Attempt to parse the server params as an SSE server
259
+ try:
260
+ server_params = SSEServerConfig(
261
+ server_name=server_name,
262
+ server_url=server_params_raw["url"],
263
+ )
264
+ mcp_server_list[server_name] = server_params
265
+ except Exception as e:
266
+ logger.error(f"Failed to parse server params for MCP server {server_name} (skipping): {e}")
267
+ continue
268
+ else:
269
+ # Attempt to parse the server params as a StdioServerParameters
270
+ try:
271
+ server_params = StdioServerConfig(
272
+ server_name=server_name,
273
+ command=server_params_raw["command"],
274
+ args=server_params_raw.get("args", []),
275
+ env=server_params_raw.get("env", {}),
276
+ )
277
+ mcp_server_list[server_name] = server_params
278
+ except Exception as e:
279
+ logger.error(f"Failed to parse server params for MCP server {server_name} (skipping): {e}")
280
+ continue
281
+ return mcp_server_list