letta-nightly 0.11.7.dev20250916104104__py3-none-any.whl → 0.11.7.dev20250918104055__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 +10 -2
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +0 -1
- letta/agent.py +4 -4
- letta/agents/agent_loop.py +2 -1
- letta/agents/base_agent.py +1 -1
- letta/agents/letta_agent.py +1 -4
- letta/agents/letta_agent_v2.py +5 -4
- letta/agents/temporal/activities/__init__.py +4 -0
- letta/agents/temporal/activities/example_activity.py +7 -0
- letta/agents/temporal/activities/prepare_messages.py +10 -0
- letta/agents/temporal/temporal_agent_workflow.py +56 -0
- letta/agents/temporal/types.py +25 -0
- letta/agents/voice_agent.py +3 -3
- letta/helpers/converters.py +8 -2
- letta/helpers/crypto_utils.py +144 -0
- letta/llm_api/llm_api_tools.py +0 -1
- letta/llm_api/llm_client_base.py +0 -2
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +9 -4
- letta/orm/job.py +3 -1
- letta/orm/mcp_oauth.py +6 -0
- letta/orm/mcp_server.py +7 -1
- letta/orm/sqlalchemy_base.py +2 -1
- letta/prompts/prompt_generator.py +4 -4
- letta/schemas/agent.py +14 -200
- letta/schemas/enums.py +15 -0
- letta/schemas/job.py +10 -0
- letta/schemas/mcp.py +146 -6
- letta/schemas/memory.py +216 -103
- letta/schemas/provider_trace.py +0 -2
- letta/schemas/run.py +2 -0
- letta/schemas/secret.py +378 -0
- letta/schemas/step.py +5 -1
- letta/schemas/tool_rule.py +34 -44
- letta/serialize_schemas/marshmallow_agent.py +4 -0
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +9 -4
- letta/server/rest_api/routers/v1/archives.py +113 -0
- letta/server/rest_api/routers/v1/jobs.py +7 -2
- letta/server/rest_api/routers/v1/runs.py +9 -1
- letta/server/rest_api/routers/v1/steps.py +29 -0
- letta/server/rest_api/routers/v1/tools.py +7 -26
- letta/server/server.py +2 -2
- letta/services/agent_manager.py +21 -15
- letta/services/agent_serialization_manager.py +11 -3
- letta/services/archive_manager.py +73 -0
- letta/services/helpers/agent_manager_helper.py +10 -5
- letta/services/job_manager.py +18 -2
- letta/services/mcp_manager.py +198 -82
- letta/services/step_manager.py +26 -0
- letta/services/summarizer/summarizer.py +25 -3
- letta/services/telemetry_manager.py +2 -0
- letta/services/tool_executor/composio_tool_executor.py +1 -1
- letta/services/tool_executor/sandbox_tool_executor.py +2 -2
- letta/services/tool_sandbox/base.py +135 -9
- letta/settings.py +2 -2
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/METADATA +6 -3
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/RECORD +62 -55
- letta/templates/template_helper.py +0 -53
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/licenses/LICENSE +0 -0
@@ -94,7 +94,7 @@ class PromptGenerator:
|
|
94
94
|
timezone: str,
|
95
95
|
user_defined_variables: Optional[dict] = None,
|
96
96
|
append_icm_if_missing: bool = True,
|
97
|
-
template_format: Literal["f-string", "mustache"
|
97
|
+
template_format: Literal["f-string", "mustache"] = "f-string",
|
98
98
|
previous_message_count: int = 0,
|
99
99
|
archival_memory_size: int = 0,
|
100
100
|
archive_tags: Optional[List[str]] = None,
|
@@ -150,7 +150,7 @@ class PromptGenerator:
|
|
150
150
|
raise ValueError(f"Failed to format system prompt - {str(e)}. System prompt value:\n{system_prompt}")
|
151
151
|
|
152
152
|
else:
|
153
|
-
# TODO support for mustache
|
153
|
+
# TODO support for mustache
|
154
154
|
raise NotImplementedError(template_format)
|
155
155
|
|
156
156
|
return formatted_prompt
|
@@ -164,7 +164,7 @@ class PromptGenerator:
|
|
164
164
|
timezone: str,
|
165
165
|
user_defined_variables: Optional[dict] = None,
|
166
166
|
append_icm_if_missing: bool = True,
|
167
|
-
template_format: Literal["f-string", "mustache"
|
167
|
+
template_format: Literal["f-string", "mustache"] = "f-string",
|
168
168
|
previous_message_count: int = 0,
|
169
169
|
archival_memory_size: int = 0,
|
170
170
|
tool_rules_solver: Optional[ToolRulesSolver] = None,
|
@@ -181,7 +181,7 @@ class PromptGenerator:
|
|
181
181
|
else:
|
182
182
|
pass
|
183
183
|
|
184
|
-
memory_with_sources =
|
184
|
+
memory_with_sources = in_context_memory.compile(
|
185
185
|
tool_usage_rules=tool_constraint_block, sources=sources, max_files_open=max_files_open
|
186
186
|
)
|
187
187
|
|
letta/schemas/agent.py
CHANGED
@@ -22,6 +22,8 @@ from letta.schemas.tool_rule import ToolRule
|
|
22
22
|
from letta.utils import calculate_file_defaults_based_on_context_window, create_random_username
|
23
23
|
|
24
24
|
|
25
|
+
# TODO: Remove this upon next OSS release, there's a duplicate AgentType in enums
|
26
|
+
# TODO: This is done in the interest of time to avoid needing to update the sandbox template IDs on cloud/rebuild
|
25
27
|
class AgentType(str, Enum):
|
26
28
|
"""
|
27
29
|
Enum to represent the type of agent.
|
@@ -86,6 +88,11 @@ class AgentState(OrmMetadataBase, validate_assignment=True):
|
|
86
88
|
sources: List[Source] = Field(..., description="The sources used by the agent.")
|
87
89
|
tags: List[str] = Field(..., description="The tags associated with the agent.")
|
88
90
|
tool_exec_environment_variables: List[AgentEnvironmentVariable] = Field(
|
91
|
+
default_factory=list,
|
92
|
+
description="Deprecated: use `secrets` field instead.",
|
93
|
+
deprecated=True,
|
94
|
+
)
|
95
|
+
secrets: List[AgentEnvironmentVariable] = Field(
|
89
96
|
default_factory=list, description="The environment variables for tool execution specific to this agent."
|
90
97
|
)
|
91
98
|
project_id: Optional[str] = Field(None, description="The id of the project the agent belongs to.")
|
@@ -133,7 +140,7 @@ class AgentState(OrmMetadataBase, validate_assignment=True):
|
|
133
140
|
def get_agent_env_vars_as_dict(self) -> Dict[str, str]:
|
134
141
|
# Get environment variables for this agent specifically
|
135
142
|
per_agent_env_vars = {}
|
136
|
-
for agent_env_var_obj in self.
|
143
|
+
for agent_env_var_obj in self.secrets:
|
137
144
|
per_agent_env_vars[agent_env_var_obj.key] = agent_env_var_obj.value
|
138
145
|
return per_agent_env_vars
|
139
146
|
|
@@ -222,9 +229,8 @@ class CreateAgent(BaseModel, validate_assignment=True): #
|
|
222
229
|
deprecated=True,
|
223
230
|
description="Deprecated: Project should now be passed via the X-Project header instead of in the request body. If using the sdk, this can be done via the new x_project field below.",
|
224
231
|
)
|
225
|
-
tool_exec_environment_variables: Optional[Dict[str, str]] = Field(
|
226
|
-
|
227
|
-
)
|
232
|
+
tool_exec_environment_variables: Optional[Dict[str, str]] = Field(None, description="Deprecated: use `secrets` field instead.")
|
233
|
+
secrets: Optional[Dict[str, str]] = Field(None, description="The environment variables for tool execution specific to this agent.")
|
228
234
|
memory_variables: Optional[Dict[str, str]] = Field(None, description="The variables that should be set for the agent.")
|
229
235
|
project_id: Optional[str] = Field(None, description="The id of the project the agent belongs to.")
|
230
236
|
template_id: Optional[str] = Field(None, description="The id of the template the agent belongs to.")
|
@@ -328,9 +334,8 @@ class UpdateAgent(BaseModel):
|
|
328
334
|
message_ids: Optional[List[str]] = Field(None, description="The ids of the messages in the agent's in-context memory.")
|
329
335
|
description: Optional[str] = Field(None, description="The description of the agent.")
|
330
336
|
metadata: Optional[Dict] = Field(None, description="The metadata of the agent.")
|
331
|
-
tool_exec_environment_variables: Optional[Dict[str, str]] = Field(
|
332
|
-
|
333
|
-
)
|
337
|
+
tool_exec_environment_variables: Optional[Dict[str, str]] = Field(None, description="Deprecated: use `secrets` field instead")
|
338
|
+
secrets: Optional[Dict[str, str]] = Field(None, description="The environment variables for tool execution specific to this agent.")
|
334
339
|
project_id: Optional[str] = Field(None, description="The id of the project the agent belongs to.")
|
335
340
|
template_id: Optional[str] = Field(None, description="The id of the template the agent belongs to.")
|
336
341
|
base_template_id: Optional[str] = Field(None, description="The base template id of the agent.")
|
@@ -380,196 +385,5 @@ class AgentStepResponse(BaseModel):
|
|
380
385
|
|
381
386
|
|
382
387
|
def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None):
|
383
|
-
|
384
|
-
|
385
|
-
if agent_type == AgentType.react_agent or agent_type == AgentType.workflow_agent:
|
386
|
-
return (
|
387
|
-
"{% if sources %}"
|
388
|
-
"<directories>\n"
|
389
|
-
"{% if max_files_open %}"
|
390
|
-
"<file_limits>\n"
|
391
|
-
"- current_files_open={{ file_blocks|selectattr('value')|list|length }}\n"
|
392
|
-
"- max_files_open={{ max_files_open }}\n"
|
393
|
-
"</file_limits>\n"
|
394
|
-
"{% endif %}"
|
395
|
-
"{% for source in sources %}"
|
396
|
-
f'<directory name="{{{{ source.name }}}}">\n'
|
397
|
-
"{% if source.description %}"
|
398
|
-
"<description>{{ source.description }}</description>\n"
|
399
|
-
"{% endif %}"
|
400
|
-
"{% if source.instructions %}"
|
401
|
-
"<instructions>{{ source.instructions }}</instructions>\n"
|
402
|
-
"{% endif %}"
|
403
|
-
"{% if file_blocks %}"
|
404
|
-
"{% for block in file_blocks %}"
|
405
|
-
"{% if block.source_id and block.source_id == source.id %}"
|
406
|
-
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\">\n"
|
407
|
-
"<{{ block.label }}>\n"
|
408
|
-
"<description>\n"
|
409
|
-
"{{ block.description }}\n"
|
410
|
-
"</description>\n"
|
411
|
-
"<metadata>"
|
412
|
-
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
|
413
|
-
"- chars_current={{ block.value|length }}\n"
|
414
|
-
"- chars_limit={{ block.limit }}\n"
|
415
|
-
"</metadata>\n"
|
416
|
-
"<value>\n"
|
417
|
-
"{{ block.value }}\n"
|
418
|
-
"</value>\n"
|
419
|
-
"</file>\n"
|
420
|
-
"{% endif %}"
|
421
|
-
"{% endfor %}"
|
422
|
-
"{% endif %}"
|
423
|
-
"</directory>\n"
|
424
|
-
"{% endfor %}"
|
425
|
-
"</directories>"
|
426
|
-
"{% endif %}"
|
427
|
-
)
|
428
|
-
|
429
|
-
# Sleeptime agents use the MemGPT v2 memory tools (line numbers)
|
430
|
-
# MemGPT v2 tools use line-number, so core memory blocks should have line numbers
|
431
|
-
elif agent_type == AgentType.sleeptime_agent or agent_type == AgentType.memgpt_v2_agent:
|
432
|
-
return (
|
433
|
-
"<memory_blocks>\nThe following memory blocks are currently engaged in your core memory unit:\n\n"
|
434
|
-
"{% for block in blocks %}"
|
435
|
-
"<{{ block.label }}>\n"
|
436
|
-
"<description>\n"
|
437
|
-
"{{ block.description }}\n"
|
438
|
-
"</description>\n"
|
439
|
-
"<metadata>"
|
440
|
-
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
|
441
|
-
"- chars_current={{ block.value|length }}\n"
|
442
|
-
"- chars_limit={{ block.limit }}\n"
|
443
|
-
"</metadata>\n"
|
444
|
-
"<value>\n"
|
445
|
-
f"{CORE_MEMORY_LINE_NUMBER_WARNING}\n"
|
446
|
-
"{% for line in block.value.split('\\n') %}"
|
447
|
-
"Line {{ loop.index }}: {{ line }}\n"
|
448
|
-
"{% endfor %}"
|
449
|
-
"</value>\n"
|
450
|
-
"</{{ block.label }}>\n"
|
451
|
-
"{% if not loop.last %}\n{% endif %}"
|
452
|
-
"{% endfor %}"
|
453
|
-
"\n</memory_blocks>"
|
454
|
-
"\n\n{% if tool_usage_rules %}"
|
455
|
-
"<tool_usage_rules>\n"
|
456
|
-
"{{ tool_usage_rules.description }}\n\n"
|
457
|
-
"{{ tool_usage_rules.value }}\n"
|
458
|
-
"</tool_usage_rules>"
|
459
|
-
"{% endif %}"
|
460
|
-
"\n\n{% if sources %}"
|
461
|
-
"<directories>\n"
|
462
|
-
"{% if max_files_open %}"
|
463
|
-
"<file_limits>\n"
|
464
|
-
"- current_files_open={{ file_blocks|selectattr('value')|list|length }}\n"
|
465
|
-
"- max_files_open={{ max_files_open }}\n"
|
466
|
-
"</file_limits>\n"
|
467
|
-
"{% endif %}"
|
468
|
-
"{% for source in sources %}"
|
469
|
-
f'<directory name="{{{{ source.name }}}}">\n'
|
470
|
-
"{% if source.description %}"
|
471
|
-
"<description>{{ source.description }}</description>\n"
|
472
|
-
"{% endif %}"
|
473
|
-
"{% if source.instructions %}"
|
474
|
-
"<instructions>{{ source.instructions }}</instructions>\n"
|
475
|
-
"{% endif %}"
|
476
|
-
"{% if file_blocks %}"
|
477
|
-
"{% for block in file_blocks %}"
|
478
|
-
"{% if block.source_id and block.source_id == source.id %}"
|
479
|
-
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\" name=\"{{{{ block.label }}}}\">\n"
|
480
|
-
"{% if block.description %}"
|
481
|
-
"<description>\n"
|
482
|
-
"{{ block.description }}\n"
|
483
|
-
"</description>\n"
|
484
|
-
"{% endif %}"
|
485
|
-
"<metadata>"
|
486
|
-
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
|
487
|
-
"- chars_current={{ block.value|length }}\n"
|
488
|
-
"- chars_limit={{ block.limit }}\n"
|
489
|
-
"</metadata>\n"
|
490
|
-
"{% if block.value %}"
|
491
|
-
"<value>\n"
|
492
|
-
"{{ block.value }}\n"
|
493
|
-
"</value>\n"
|
494
|
-
"{% endif %}"
|
495
|
-
"</file>\n"
|
496
|
-
"{% endif %}"
|
497
|
-
"{% endfor %}"
|
498
|
-
"{% endif %}"
|
499
|
-
"</directory>\n"
|
500
|
-
"{% endfor %}"
|
501
|
-
"</directories>"
|
502
|
-
"{% endif %}"
|
503
|
-
)
|
504
|
-
|
505
|
-
# All other agent types use memory blocks
|
506
|
-
else:
|
507
|
-
return (
|
508
|
-
"<memory_blocks>\nThe following memory blocks are currently engaged in your core memory unit:\n\n"
|
509
|
-
"{% for block in blocks %}"
|
510
|
-
"<{{ block.label }}>\n"
|
511
|
-
"<description>\n"
|
512
|
-
"{{ block.description }}\n"
|
513
|
-
"</description>\n"
|
514
|
-
"<metadata>"
|
515
|
-
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
|
516
|
-
"- chars_current={{ block.value|length }}\n"
|
517
|
-
"- chars_limit={{ block.limit }}\n"
|
518
|
-
"</metadata>\n"
|
519
|
-
"<value>\n"
|
520
|
-
"{{ block.value }}\n"
|
521
|
-
"</value>\n"
|
522
|
-
"</{{ block.label }}>\n"
|
523
|
-
"{% if not loop.last %}\n{% endif %}"
|
524
|
-
"{% endfor %}"
|
525
|
-
"\n</memory_blocks>"
|
526
|
-
"\n\n{% if tool_usage_rules %}"
|
527
|
-
"<tool_usage_rules>\n"
|
528
|
-
"{{ tool_usage_rules.description }}\n\n"
|
529
|
-
"{{ tool_usage_rules.value }}\n"
|
530
|
-
"</tool_usage_rules>"
|
531
|
-
"{% endif %}"
|
532
|
-
"\n\n{% if sources %}"
|
533
|
-
"<directories>\n"
|
534
|
-
"{% if max_files_open %}"
|
535
|
-
"<file_limits>\n"
|
536
|
-
"- current_files_open={{ file_blocks|selectattr('value')|list|length }}\n"
|
537
|
-
"- max_files_open={{ max_files_open }}\n"
|
538
|
-
"</file_limits>\n"
|
539
|
-
"{% endif %}"
|
540
|
-
"{% for source in sources %}"
|
541
|
-
f'<directory name="{{{{ source.name }}}}">\n'
|
542
|
-
"{% if source.description %}"
|
543
|
-
"<description>{{ source.description }}</description>\n"
|
544
|
-
"{% endif %}"
|
545
|
-
"{% if source.instructions %}"
|
546
|
-
"<instructions>{{ source.instructions }}</instructions>\n"
|
547
|
-
"{% endif %}"
|
548
|
-
"{% if file_blocks %}"
|
549
|
-
"{% for block in file_blocks %}"
|
550
|
-
"{% if block.source_id and block.source_id == source.id %}"
|
551
|
-
f"<file status=\"{{{{ '{FileStatus.open.value}' if block.value else '{FileStatus.closed.value}' }}}}\" name=\"{{{{ block.label }}}}\">\n"
|
552
|
-
"{% if block.description %}"
|
553
|
-
"<description>\n"
|
554
|
-
"{{ block.description }}\n"
|
555
|
-
"</description>\n"
|
556
|
-
"{% endif %}"
|
557
|
-
"<metadata>"
|
558
|
-
"{% if block.read_only %}\n- read_only=true{% endif %}\n"
|
559
|
-
"- chars_current={{ block.value|length }}\n"
|
560
|
-
"- chars_limit={{ block.limit }}\n"
|
561
|
-
"</metadata>\n"
|
562
|
-
"{% if block.value %}"
|
563
|
-
"<value>\n"
|
564
|
-
"{{ block.value }}\n"
|
565
|
-
"</value>\n"
|
566
|
-
"{% endif %}"
|
567
|
-
"</file>\n"
|
568
|
-
"{% endif %}"
|
569
|
-
"{% endfor %}"
|
570
|
-
"{% endif %}"
|
571
|
-
"</directory>\n"
|
572
|
-
"{% endfor %}"
|
573
|
-
"</directories>"
|
574
|
-
"{% endif %}"
|
575
|
-
)
|
388
|
+
"""Deprecated. Templates are not used anymore; fast renderer handles formatting."""
|
389
|
+
return ""
|
letta/schemas/enums.py
CHANGED
@@ -21,6 +21,21 @@ class ProviderType(str, Enum):
|
|
21
21
|
xai = "xai"
|
22
22
|
|
23
23
|
|
24
|
+
class AgentType(str, Enum):
|
25
|
+
"""
|
26
|
+
Enum to represent the type of agent.
|
27
|
+
"""
|
28
|
+
|
29
|
+
memgpt_agent = "memgpt_agent" # the OG set of memgpt tools
|
30
|
+
memgpt_v2_agent = "memgpt_v2_agent" # memgpt style tools, but refreshed
|
31
|
+
react_agent = "react_agent" # basic react agent, no memory tools
|
32
|
+
workflow_agent = "workflow_agent" # workflow with auto-clearing message buffer
|
33
|
+
split_thread_agent = "split_thread_agent"
|
34
|
+
sleeptime_agent = "sleeptime_agent"
|
35
|
+
voice_convo_agent = "voice_convo_agent"
|
36
|
+
voice_sleeptime_agent = "voice_sleeptime_agent"
|
37
|
+
|
38
|
+
|
24
39
|
class ProviderCategory(str, Enum):
|
25
40
|
base = "base"
|
26
41
|
byok = "byok"
|
letta/schemas/job.py
CHANGED
@@ -8,16 +8,26 @@ from letta.helpers.datetime_helpers import get_utc_time
|
|
8
8
|
from letta.schemas.enums import JobStatus, JobType
|
9
9
|
from letta.schemas.letta_base import OrmMetadataBase
|
10
10
|
from letta.schemas.letta_message import MessageType
|
11
|
+
from letta.schemas.letta_stop_reason import StopReasonType
|
11
12
|
|
12
13
|
|
13
14
|
class JobBase(OrmMetadataBase):
|
14
15
|
__id_prefix__ = "job"
|
15
16
|
status: JobStatus = Field(default=JobStatus.created, description="The status of the job.")
|
16
17
|
created_at: datetime = Field(default_factory=get_utc_time, description="The unix timestamp of when the job was created.")
|
18
|
+
|
19
|
+
# completion related
|
17
20
|
completed_at: Optional[datetime] = Field(None, description="The unix timestamp of when the job was completed.")
|
21
|
+
stop_reason: Optional[StopReasonType] = Field(None, description="The reason why the job was stopped.")
|
22
|
+
|
23
|
+
# metadata
|
18
24
|
metadata: Optional[dict] = Field(None, validation_alias="metadata_", description="The metadata of the job.")
|
19
25
|
job_type: JobType = Field(default=JobType.JOB, description="The type of the job.")
|
20
26
|
|
27
|
+
## TODO: Run-specific fields
|
28
|
+
# background: Optional[bool] = Field(None, description="Whether the job was created in background mode.")
|
29
|
+
# agent_id: Optional[str] = Field(None, description="The agent associated with this job/run.")
|
30
|
+
|
21
31
|
callback_url: Optional[str] = Field(None, description="If set, POST to this URL when the job completes.")
|
22
32
|
callback_sent_at: Optional[datetime] = Field(None, description="Timestamp when the callback was last attempted.")
|
23
33
|
callback_status_code: Optional[int] = Field(None, description="HTTP status code returned by the callback endpoint.")
|
letta/schemas/mcp.py
CHANGED
@@ -13,6 +13,8 @@ from letta.functions.mcp_client.types import (
|
|
13
13
|
)
|
14
14
|
from letta.orm.mcp_oauth import OAuthSessionStatus
|
15
15
|
from letta.schemas.letta_base import LettaBase
|
16
|
+
from letta.schemas.secret import Secret, SecretDict
|
17
|
+
from letta.settings import settings
|
16
18
|
|
17
19
|
|
18
20
|
class BaseMCPServer(LettaBase):
|
@@ -29,6 +31,9 @@ class MCPServer(BaseMCPServer):
|
|
29
31
|
token: Optional[str] = Field(None, description="The access token or API key for the MCP server (used for authentication)")
|
30
32
|
custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom authentication headers as key-value pairs")
|
31
33
|
|
34
|
+
token_enc: Optional[str] = Field(None, description="Encrypted token")
|
35
|
+
custom_headers_enc: Optional[str] = Field(None, description="Encrypted custom headers")
|
36
|
+
|
32
37
|
# stdio config
|
33
38
|
stdio_config: Optional[StdioServerConfig] = Field(
|
34
39
|
None, description="The configuration for the server (MCP 'local' client will run this command)"
|
@@ -41,18 +46,76 @@ class MCPServer(BaseMCPServer):
|
|
41
46
|
last_updated_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
|
42
47
|
metadata_: Optional[Dict[str, Any]] = Field(default_factory=dict, description="A dictionary of additional metadata for the tool.")
|
43
48
|
|
49
|
+
def get_token_secret(self) -> Secret:
|
50
|
+
"""Get the token as a Secret object, preferring encrypted over plaintext."""
|
51
|
+
return Secret.from_db(self.token_enc, self.token)
|
52
|
+
|
53
|
+
def get_custom_headers_secret(self) -> SecretDict:
|
54
|
+
"""Get custom headers as a SecretDict object, preferring encrypted over plaintext."""
|
55
|
+
return SecretDict.from_db(self.custom_headers_enc, self.custom_headers)
|
56
|
+
|
57
|
+
def set_token_secret(self, secret: Secret) -> None:
|
58
|
+
"""Set token from a Secret object, updating both encrypted and plaintext fields."""
|
59
|
+
secret_dict = secret.to_dict()
|
60
|
+
self.token_enc = secret_dict["encrypted"]
|
61
|
+
# Only set plaintext during migration phase
|
62
|
+
if not secret._was_encrypted:
|
63
|
+
self.token = secret_dict["plaintext"]
|
64
|
+
else:
|
65
|
+
self.token = None
|
66
|
+
|
67
|
+
def set_custom_headers_secret(self, secret: SecretDict) -> None:
|
68
|
+
"""Set custom headers from a SecretDict object, updating both fields."""
|
69
|
+
secret_dict = secret.to_dict()
|
70
|
+
self.custom_headers_enc = secret_dict["encrypted"]
|
71
|
+
# Only set plaintext during migration phase
|
72
|
+
if not secret._was_encrypted:
|
73
|
+
self.custom_headers = secret_dict["plaintext"]
|
74
|
+
else:
|
75
|
+
self.custom_headers = None
|
76
|
+
|
77
|
+
def model_dump(self, to_orm: bool = False, **kwargs):
|
78
|
+
"""Override model_dump to handle encryption when saving to database."""
|
79
|
+
data = super().model_dump(to_orm=to_orm, **kwargs)
|
80
|
+
|
81
|
+
if to_orm and settings.encryption_key:
|
82
|
+
# Encrypt token if present
|
83
|
+
if self.token is not None:
|
84
|
+
token_secret = Secret.from_plaintext(self.token)
|
85
|
+
secret_dict = token_secret.to_dict()
|
86
|
+
data["token_enc"] = secret_dict["encrypted"]
|
87
|
+
# Keep plaintext for dual-write during migration
|
88
|
+
data["token"] = secret_dict["plaintext"]
|
89
|
+
|
90
|
+
# Encrypt custom headers if present
|
91
|
+
if self.custom_headers is not None:
|
92
|
+
headers_secret = SecretDict.from_plaintext(self.custom_headers)
|
93
|
+
secret_dict = headers_secret.to_dict()
|
94
|
+
data["custom_headers_enc"] = secret_dict["encrypted"]
|
95
|
+
# Keep plaintext for dual-write during migration
|
96
|
+
data["custom_headers"] = secret_dict["plaintext"]
|
97
|
+
|
98
|
+
return data
|
99
|
+
|
44
100
|
def to_config(
|
45
101
|
self,
|
46
102
|
environment_variables: Optional[Dict[str, str]] = None,
|
47
103
|
resolve_variables: bool = True,
|
48
104
|
) -> Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig]:
|
105
|
+
# Get decrypted values for use in config
|
106
|
+
token_secret = self.get_token_secret()
|
107
|
+
token_plaintext = token_secret.get_plaintext()
|
108
|
+
|
109
|
+
headers_secret = self.get_custom_headers_secret()
|
110
|
+
headers_plaintext = headers_secret.get_plaintext()
|
111
|
+
|
49
112
|
if self.server_type == MCPServerType.SSE:
|
50
113
|
config = SSEServerConfig(
|
51
114
|
server_name=self.server_name,
|
52
115
|
server_url=self.server_url,
|
53
|
-
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if
|
54
|
-
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {
|
55
|
-
custom_headers=
|
116
|
+
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if token_plaintext and not headers_plaintext else None,
|
117
|
+
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {token_plaintext}" if token_plaintext and not headers_plaintext else None,
|
118
|
+
custom_headers=headers_plaintext,
|
56
119
|
)
|
57
120
|
if resolve_variables:
|
58
121
|
config.resolve_environment_variables(environment_variables)
|
@@ -70,9 +133,9 @@ class MCPServer(BaseMCPServer):
|
|
70
133
|
config = StreamableHTTPServerConfig(
|
71
134
|
server_name=self.server_name,
|
72
135
|
server_url=self.server_url,
|
73
|
-
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if
|
74
|
-
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {
|
75
|
-
custom_headers=
|
136
|
+
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if token_plaintext and not headers_plaintext else None,
|
137
|
+
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {token_plaintext}" if token_plaintext and not headers_plaintext else None,
|
138
|
+
custom_headers=headers_plaintext,
|
76
139
|
)
|
77
140
|
if resolve_variables:
|
78
141
|
config.resolve_environment_variables(environment_variables)
|
@@ -138,11 +201,18 @@ class MCPOAuthSession(BaseMCPOAuth):
|
|
138
201
|
expires_at: Optional[datetime] = Field(None, description="Token expiry time")
|
139
202
|
scope: Optional[str] = Field(None, description="OAuth scope")
|
140
203
|
|
204
|
+
# Encrypted token fields (for internal use)
|
205
|
+
access_token_enc: Optional[str] = Field(None, description="Encrypted OAuth access token")
|
206
|
+
refresh_token_enc: Optional[str] = Field(None, description="Encrypted OAuth refresh token")
|
207
|
+
|
141
208
|
# Client configuration
|
142
209
|
client_id: Optional[str] = Field(None, description="OAuth client ID")
|
143
210
|
client_secret: Optional[str] = Field(None, description="OAuth client secret")
|
144
211
|
redirect_uri: Optional[str] = Field(None, description="OAuth redirect URI")
|
145
212
|
|
213
|
+
# Encrypted client secret (for internal use)
|
214
|
+
client_secret_enc: Optional[str] = Field(None, description="Encrypted OAuth client secret")
|
215
|
+
|
146
216
|
# Session state
|
147
217
|
status: OAuthSessionStatus = Field(default=OAuthSessionStatus.PENDING, description="Session status")
|
148
218
|
|
@@ -150,6 +220,76 @@ class MCPOAuthSession(BaseMCPOAuth):
|
|
150
220
|
created_at: datetime = Field(default_factory=datetime.now, description="Session creation time")
|
151
221
|
updated_at: datetime = Field(default_factory=datetime.now, description="Last update time")
|
152
222
|
|
223
|
+
def get_access_token_secret(self) -> Secret:
|
224
|
+
"""Get the access token as a Secret object, preferring encrypted over plaintext."""
|
225
|
+
return Secret.from_db(self.access_token_enc, self.access_token)
|
226
|
+
|
227
|
+
def get_refresh_token_secret(self) -> Secret:
|
228
|
+
"""Get the refresh token as a Secret object, preferring encrypted over plaintext."""
|
229
|
+
return Secret.from_db(self.refresh_token_enc, self.refresh_token)
|
230
|
+
|
231
|
+
def get_client_secret_secret(self) -> Secret:
|
232
|
+
"""Get the client secret as a Secret object, preferring encrypted over plaintext."""
|
233
|
+
return Secret.from_db(self.client_secret_enc, self.client_secret)
|
234
|
+
|
235
|
+
def set_access_token_secret(self, secret: Secret) -> None:
|
236
|
+
"""Set access token from a Secret object."""
|
237
|
+
secret_dict = secret.to_dict()
|
238
|
+
self.access_token_enc = secret_dict["encrypted"]
|
239
|
+
if not secret._was_encrypted:
|
240
|
+
self.access_token = secret_dict["plaintext"]
|
241
|
+
else:
|
242
|
+
self.access_token = None
|
243
|
+
|
244
|
+
def set_refresh_token_secret(self, secret: Secret) -> None:
|
245
|
+
"""Set refresh token from a Secret object."""
|
246
|
+
secret_dict = secret.to_dict()
|
247
|
+
self.refresh_token_enc = secret_dict["encrypted"]
|
248
|
+
if not secret._was_encrypted:
|
249
|
+
self.refresh_token = secret_dict["plaintext"]
|
250
|
+
else:
|
251
|
+
self.refresh_token = None
|
252
|
+
|
253
|
+
def set_client_secret_secret(self, secret: Secret) -> None:
|
254
|
+
"""Set client secret from a Secret object."""
|
255
|
+
secret_dict = secret.to_dict()
|
256
|
+
self.client_secret_enc = secret_dict["encrypted"]
|
257
|
+
if not secret._was_encrypted:
|
258
|
+
self.client_secret = secret_dict["plaintext"]
|
259
|
+
else:
|
260
|
+
self.client_secret = None
|
261
|
+
|
262
|
+
def model_dump(self, to_orm: bool = False, **kwargs):
|
263
|
+
"""Override model_dump to handle encryption when saving to database."""
|
264
|
+
data = super().model_dump(to_orm=to_orm, **kwargs)
|
265
|
+
|
266
|
+
if to_orm and settings.encryption_key:
|
267
|
+
# Encrypt access token if present
|
268
|
+
if self.access_token is not None:
|
269
|
+
token_secret = Secret.from_plaintext(self.access_token)
|
270
|
+
secret_dict = token_secret.to_dict()
|
271
|
+
data["access_token_enc"] = secret_dict["encrypted"]
|
272
|
+
# Keep plaintext for dual-write during migration
|
273
|
+
data["access_token"] = secret_dict["plaintext"]
|
274
|
+
|
275
|
+
# Encrypt refresh token if present
|
276
|
+
if self.refresh_token is not None:
|
277
|
+
token_secret = Secret.from_plaintext(self.refresh_token)
|
278
|
+
secret_dict = token_secret.to_dict()
|
279
|
+
data["refresh_token_enc"] = secret_dict["encrypted"]
|
280
|
+
# Keep plaintext for dual-write during migration
|
281
|
+
data["refresh_token"] = secret_dict["plaintext"]
|
282
|
+
|
283
|
+
# Encrypt client secret if present
|
284
|
+
if self.client_secret is not None:
|
285
|
+
secret = Secret.from_plaintext(self.client_secret)
|
286
|
+
secret_dict = secret.to_dict()
|
287
|
+
data["client_secret_enc"] = secret_dict["encrypted"]
|
288
|
+
# Keep plaintext for dual-write during migration
|
289
|
+
data["client_secret"] = secret_dict["plaintext"]
|
290
|
+
|
291
|
+
return data
|
292
|
+
|
153
293
|
|
154
294
|
class MCPOAuthSessionCreate(BaseMCPOAuth):
|
155
295
|
"""Create a new OAuth session."""
|