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.
Files changed (63) hide show
  1. letta/__init__.py +10 -2
  2. letta/adapters/letta_llm_request_adapter.py +0 -1
  3. letta/adapters/letta_llm_stream_adapter.py +0 -1
  4. letta/agent.py +4 -4
  5. letta/agents/agent_loop.py +2 -1
  6. letta/agents/base_agent.py +1 -1
  7. letta/agents/letta_agent.py +1 -4
  8. letta/agents/letta_agent_v2.py +5 -4
  9. letta/agents/temporal/activities/__init__.py +4 -0
  10. letta/agents/temporal/activities/example_activity.py +7 -0
  11. letta/agents/temporal/activities/prepare_messages.py +10 -0
  12. letta/agents/temporal/temporal_agent_workflow.py +56 -0
  13. letta/agents/temporal/types.py +25 -0
  14. letta/agents/voice_agent.py +3 -3
  15. letta/helpers/converters.py +8 -2
  16. letta/helpers/crypto_utils.py +144 -0
  17. letta/llm_api/llm_api_tools.py +0 -1
  18. letta/llm_api/llm_client_base.py +0 -2
  19. letta/orm/__init__.py +1 -0
  20. letta/orm/agent.py +9 -4
  21. letta/orm/job.py +3 -1
  22. letta/orm/mcp_oauth.py +6 -0
  23. letta/orm/mcp_server.py +7 -1
  24. letta/orm/sqlalchemy_base.py +2 -1
  25. letta/prompts/prompt_generator.py +4 -4
  26. letta/schemas/agent.py +14 -200
  27. letta/schemas/enums.py +15 -0
  28. letta/schemas/job.py +10 -0
  29. letta/schemas/mcp.py +146 -6
  30. letta/schemas/memory.py +216 -103
  31. letta/schemas/provider_trace.py +0 -2
  32. letta/schemas/run.py +2 -0
  33. letta/schemas/secret.py +378 -0
  34. letta/schemas/step.py +5 -1
  35. letta/schemas/tool_rule.py +34 -44
  36. letta/serialize_schemas/marshmallow_agent.py +4 -0
  37. letta/server/rest_api/routers/v1/__init__.py +2 -0
  38. letta/server/rest_api/routers/v1/agents.py +9 -4
  39. letta/server/rest_api/routers/v1/archives.py +113 -0
  40. letta/server/rest_api/routers/v1/jobs.py +7 -2
  41. letta/server/rest_api/routers/v1/runs.py +9 -1
  42. letta/server/rest_api/routers/v1/steps.py +29 -0
  43. letta/server/rest_api/routers/v1/tools.py +7 -26
  44. letta/server/server.py +2 -2
  45. letta/services/agent_manager.py +21 -15
  46. letta/services/agent_serialization_manager.py +11 -3
  47. letta/services/archive_manager.py +73 -0
  48. letta/services/helpers/agent_manager_helper.py +10 -5
  49. letta/services/job_manager.py +18 -2
  50. letta/services/mcp_manager.py +198 -82
  51. letta/services/step_manager.py +26 -0
  52. letta/services/summarizer/summarizer.py +25 -3
  53. letta/services/telemetry_manager.py +2 -0
  54. letta/services/tool_executor/composio_tool_executor.py +1 -1
  55. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  56. letta/services/tool_sandbox/base.py +135 -9
  57. letta/settings.py +2 -2
  58. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/METADATA +6 -3
  59. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/RECORD +62 -55
  60. letta/templates/template_helper.py +0 -53
  61. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/WHEEL +0 -0
  62. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250918104055.dist-info}/entry_points.txt +0 -0
  63. {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", "jinja2"] = "f-string",
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 and jinja2
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", "jinja2"] = "f-string",
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 = await in_context_memory.compile_in_thread_async(
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.tool_exec_environment_variables:
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
- None, description="The environment variables for tool execution specific to this agent."
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
- None, description="The environment variables for tool execution specific to this agent."
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
- # Workflow agents and ReAct agents don't use memory blocks
384
- # However, they still allow files to be injected into the context
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 self.token and not self.custom_headers else None,
54
- auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token and not self.custom_headers else None,
55
- custom_headers=self.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 self.token and not self.custom_headers else None,
74
- auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token and not self.custom_headers else None,
75
- custom_headers=self.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."""