letta-nightly 0.7.29.dev20250602104315__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.
- letta/__init__.py +7 -1
- letta/agent.py +16 -9
- letta/agents/base_agent.py +1 -0
- letta/agents/ephemeral_summary_agent.py +104 -0
- letta/agents/helpers.py +35 -3
- letta/agents/letta_agent.py +492 -176
- letta/agents/letta_agent_batch.py +22 -16
- letta/agents/prompts/summary_system_prompt.txt +62 -0
- letta/agents/voice_agent.py +22 -7
- letta/agents/voice_sleeptime_agent.py +13 -8
- letta/constants.py +33 -1
- letta/data_sources/connectors.py +52 -36
- letta/errors.py +4 -0
- letta/functions/ast_parsers.py +13 -30
- letta/functions/function_sets/base.py +3 -1
- letta/functions/functions.py +2 -0
- letta/functions/mcp_client/base_client.py +151 -97
- letta/functions/mcp_client/sse_client.py +49 -31
- letta/functions/mcp_client/stdio_client.py +107 -106
- letta/functions/schema_generator.py +22 -22
- letta/groups/helpers.py +3 -4
- letta/groups/sleeptime_multi_agent.py +4 -4
- letta/groups/sleeptime_multi_agent_v2.py +22 -0
- letta/helpers/composio_helpers.py +16 -0
- letta/helpers/converters.py +20 -0
- letta/helpers/datetime_helpers.py +1 -6
- letta/helpers/tool_rule_solver.py +2 -1
- letta/interfaces/anthropic_streaming_interface.py +17 -2
- letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
- letta/interfaces/openai_streaming_interface.py +18 -2
- letta/jobs/llm_batch_job_polling.py +1 -1
- letta/jobs/scheduler.py +1 -1
- letta/llm_api/anthropic_client.py +24 -3
- letta/llm_api/google_ai_client.py +0 -15
- letta/llm_api/google_vertex_client.py +6 -5
- letta/llm_api/llm_client_base.py +15 -0
- letta/llm_api/openai.py +2 -2
- letta/llm_api/openai_client.py +60 -8
- letta/orm/__init__.py +2 -0
- letta/orm/agent.py +45 -43
- letta/orm/base.py +0 -2
- letta/orm/block.py +1 -0
- letta/orm/custom_columns.py +13 -0
- letta/orm/enums.py +5 -0
- letta/orm/file.py +3 -1
- letta/orm/files_agents.py +68 -0
- letta/orm/mcp_server.py +48 -0
- letta/orm/message.py +1 -0
- letta/orm/organization.py +11 -2
- letta/orm/passage.py +25 -10
- letta/orm/sandbox_config.py +5 -2
- letta/orm/sqlalchemy_base.py +171 -110
- letta/prompts/system/memgpt_base.txt +6 -1
- letta/prompts/system/memgpt_v2_chat.txt +57 -0
- letta/prompts/system/sleeptime.txt +2 -0
- letta/prompts/system/sleeptime_v2.txt +28 -0
- letta/schemas/agent.py +87 -20
- letta/schemas/block.py +7 -1
- letta/schemas/file.py +57 -0
- letta/schemas/mcp.py +74 -0
- letta/schemas/memory.py +5 -2
- letta/schemas/message.py +9 -0
- letta/schemas/openai/openai.py +0 -6
- letta/schemas/providers.py +33 -4
- letta/schemas/tool.py +26 -21
- letta/schemas/tool_execution_result.py +5 -0
- letta/server/db.py +23 -8
- letta/server/rest_api/app.py +73 -56
- letta/server/rest_api/interface.py +4 -4
- letta/server/rest_api/routers/v1/agents.py +132 -47
- letta/server/rest_api/routers/v1/blocks.py +3 -2
- letta/server/rest_api/routers/v1/embeddings.py +3 -3
- letta/server/rest_api/routers/v1/groups.py +3 -3
- letta/server/rest_api/routers/v1/jobs.py +14 -17
- letta/server/rest_api/routers/v1/organizations.py +10 -10
- letta/server/rest_api/routers/v1/providers.py +12 -10
- letta/server/rest_api/routers/v1/runs.py +3 -3
- letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
- letta/server/rest_api/routers/v1/sources.py +108 -43
- letta/server/rest_api/routers/v1/steps.py +8 -6
- letta/server/rest_api/routers/v1/tools.py +134 -95
- letta/server/rest_api/utils.py +12 -1
- letta/server/server.py +272 -73
- letta/services/agent_manager.py +246 -313
- letta/services/block_manager.py +30 -9
- letta/services/context_window_calculator/__init__.py +0 -0
- letta/services/context_window_calculator/context_window_calculator.py +150 -0
- letta/services/context_window_calculator/token_counter.py +82 -0
- letta/services/file_processor/__init__.py +0 -0
- letta/services/file_processor/chunker/__init__.py +0 -0
- letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
- letta/services/file_processor/embedder/__init__.py +0 -0
- letta/services/file_processor/embedder/openai_embedder.py +84 -0
- letta/services/file_processor/file_processor.py +123 -0
- letta/services/file_processor/parser/__init__.py +0 -0
- letta/services/file_processor/parser/base_parser.py +9 -0
- letta/services/file_processor/parser/mistral_parser.py +54 -0
- letta/services/file_processor/types.py +0 -0
- letta/services/files_agents_manager.py +184 -0
- letta/services/group_manager.py +118 -0
- letta/services/helpers/agent_manager_helper.py +76 -21
- letta/services/helpers/tool_execution_helper.py +3 -0
- letta/services/helpers/tool_parser_helper.py +100 -0
- letta/services/identity_manager.py +44 -42
- letta/services/job_manager.py +21 -10
- letta/services/mcp/base_client.py +5 -2
- letta/services/mcp/sse_client.py +3 -5
- letta/services/mcp/stdio_client.py +3 -5
- letta/services/mcp_manager.py +281 -0
- letta/services/message_manager.py +40 -26
- letta/services/organization_manager.py +55 -19
- letta/services/passage_manager.py +211 -13
- letta/services/provider_manager.py +48 -2
- letta/services/sandbox_config_manager.py +105 -0
- letta/services/source_manager.py +4 -5
- letta/services/step_manager.py +9 -6
- letta/services/summarizer/summarizer.py +50 -23
- letta/services/telemetry_manager.py +7 -0
- letta/services/tool_executor/tool_execution_manager.py +11 -52
- letta/services/tool_executor/tool_execution_sandbox.py +4 -34
- letta/services/tool_executor/tool_executor.py +107 -105
- letta/services/tool_manager.py +56 -17
- letta/services/tool_sandbox/base.py +39 -92
- letta/services/tool_sandbox/e2b_sandbox.py +16 -11
- letta/services/tool_sandbox/local_sandbox.py +51 -23
- letta/services/user_manager.py +36 -3
- letta/settings.py +10 -3
- letta/templates/__init__.py +0 -0
- letta/templates/sandbox_code_file.py.j2 +47 -0
- letta/templates/template_helper.py +16 -0
- letta/tracing.py +30 -1
- letta/types/__init__.py +7 -0
- letta/utils.py +25 -1
- {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/METADATA +7 -2
- {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/RECORD +138 -112
- {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/LICENSE +0 -0
- {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/WHEEL +0 -0
- {letta_nightly-0.7.29.dev20250602104315.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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
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):
|
letta/services/job_manager.py
CHANGED
@@ -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
|
-
|
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
|
-
|
621
|
-
|
622
|
-
|
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,
|
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")
|
letta/services/mcp/sse_client.py
CHANGED
@@ -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,
|
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,
|
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
|