@letta-ai/letta-agent-sdk 0.5.6 → 0.5.7

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.
package/src/mcp.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
1
  import {
3
- getDefaultEnvironment,
4
- StdioClientTransport,
5
- } from "@modelcontextprotocol/sdk/client/stdio.js";
2
+ type ConnectedMcpServer,
3
+ connectMcpServer,
4
+ type McpServerConfig as LettaMcpServerConfig,
5
+ type McpToolDefinition,
6
+ } from "@letta-ai/letta-code/mcp-client";
6
7
  import type {
7
8
  ConnectMcpServersOptions,
8
9
  McpToolBridge,
@@ -11,6 +12,7 @@ import type {
11
12
  AgentToolResultContent,
12
13
  AnyAgentTool,
13
14
  McpServerConfig,
15
+ McpServers,
14
16
  } from "./types.js";
15
17
 
16
18
  const EMPTY_BRIDGE: McpToolBridge = {
@@ -23,98 +25,119 @@ const CLIENT_INFO = {
23
25
  version: "1",
24
26
  };
25
27
 
28
+ type NamedMcpServer = {
29
+ name: string;
30
+ config: McpServerConfig;
31
+ };
32
+
33
+ type ConnectionResult =
34
+ | {
35
+ server: NamedMcpServer;
36
+ connection: ConnectedMcpServer;
37
+ }
38
+ | {
39
+ server: NamedMcpServer;
40
+ error: unknown;
41
+ };
42
+
26
43
  /**
27
- * Connect stdio MCP servers and expose their tools through Letta Code's
28
- * external-tool protocol. A broken server is skipped so it cannot prevent the
29
- * rest of a session from starting.
44
+ * Connect session-scoped MCP servers through Letta Code's transport-neutral
45
+ * client and expose their tools through the external-tool protocol. A broken
46
+ * server is reported but cannot prevent healthy servers from loading.
30
47
  */
31
48
  export async function connectMcpServers(
32
- servers: readonly McpServerConfig[] | undefined,
49
+ servers: McpServers | undefined,
33
50
  options: ConnectMcpServersOptions = {},
34
51
  ): Promise<McpToolBridge> {
35
- if (!servers || servers.length === 0) return EMPTY_BRIDGE;
52
+ const namedServers = Object.entries(servers ?? {}).map(([name, config]) => ({
53
+ name,
54
+ config,
55
+ }));
56
+ if (namedServers.length === 0) return EMPTY_BRIDGE;
36
57
 
37
58
  const log = options.log ?? ((message: string) => console.error(message));
38
- const clients: Client[] = [];
59
+ const results = await Promise.all(
60
+ namedServers.map(async (server): Promise<ConnectionResult> => {
61
+ try {
62
+ const connection = await connectMcpServer(
63
+ toLettaMcpServerConfig(server, options.cwd),
64
+ {
65
+ clientInfo: CLIENT_INFO,
66
+ stderr: "inherit",
67
+ },
68
+ );
69
+ return { server, connection };
70
+ } catch (error) {
71
+ return { server, error };
72
+ }
73
+ }),
74
+ );
75
+
76
+ const connections: ConnectedMcpServer[] = [];
39
77
  const tools: AnyAgentTool[] = [];
40
78
  const taken = new Set(options.reservedToolNames ?? []);
41
79
 
42
- for (const server of servers) {
43
- let client: Client | null = null;
44
- try {
45
- client = new Client(CLIENT_INFO);
46
- await client.connect(
47
- new StdioClientTransport({
48
- command: server.command,
49
- args: server.args ?? [],
50
- env: {
51
- ...getDefaultEnvironment(),
52
- ...normalizeEnvironment(server.env),
53
- },
54
- cwd: server.cwd ?? options.cwd,
55
- stderr: "inherit",
56
- }),
80
+ for (const result of results) {
81
+ if ("error" in result) {
82
+ log(
83
+ `MCP server "${result.server.name}" unavailable: ${errorMessage(result.error)}`,
57
84
  );
85
+ continue;
86
+ }
58
87
 
59
- const listed = await client.listTools();
60
- clients.push(client);
61
- for (const tool of listed.tools) {
62
- const name = uniqueName(
63
- `mcp__${sanitize(server.name)}__${sanitize(tool.name)}`,
64
- taken,
65
- );
66
- tools.push(
67
- bridgeTool(client, server.name, tool, name),
68
- );
69
- }
70
- log(
71
- `MCP server "${server.name}" connected (${listed.tools.length} tool${listed.tools.length === 1 ? "" : "s"})`,
88
+ connections.push(result.connection);
89
+ for (const tool of result.connection.tools) {
90
+ const name = uniqueName(
91
+ `mcp__${sanitize(result.server.name)}__${sanitize(tool.name)}`,
92
+ taken,
72
93
  );
73
- } catch (error) {
74
- if (client && !clients.includes(client)) {
75
- await client.close().catch(() => undefined);
76
- }
77
- log(`MCP server "${server.name}" unavailable: ${String(error)}`);
94
+ tools.push(bridgeTool(result.connection, result.server.name, tool, name));
78
95
  }
96
+ log(
97
+ `MCP server "${result.server.name}" connected (${result.connection.tools.length} tool${result.connection.tools.length === 1 ? "" : "s"})`,
98
+ );
79
99
  }
80
100
 
81
- if (clients.length === 0) return EMPTY_BRIDGE;
82
-
83
101
  let closed = false;
84
102
  return {
85
103
  tools,
86
104
  close: async () => {
87
105
  if (closed) return;
88
106
  closed = true;
89
- await Promise.all(
90
- clients.map((client) => client.close().catch(() => undefined)),
107
+ await Promise.allSettled(
108
+ connections.map((connection) => connection.close()),
91
109
  );
92
110
  },
93
111
  };
94
112
  }
95
113
 
96
- function normalizeEnvironment(
97
- env: McpServerConfig["env"],
98
- ): Record<string, string> {
99
- if (!env) return {};
100
- if (!Array.isArray(env)) return env;
101
-
102
- const result: Record<string, string> = {};
103
- for (const entry of env) {
104
- result[entry.name] = entry.value;
114
+ function toLettaMcpServerConfig(
115
+ server: NamedMcpServer,
116
+ sessionCwd: string | undefined,
117
+ ): LettaMcpServerConfig {
118
+ const config = server.config;
119
+ if (config.type === "http" || config.type === "sse") {
120
+ return {
121
+ name: server.name,
122
+ transport: config.type,
123
+ url: config.url,
124
+ ...(config.headers ? { headers: config.headers } : {}),
125
+ };
105
126
  }
106
- return result;
107
- }
108
-
109
- interface McpToolDefinition {
110
- name: string;
111
- title?: string;
112
- description?: string;
113
- inputSchema?: unknown;
127
+ return {
128
+ name: server.name,
129
+ transport: "stdio",
130
+ command: config.command,
131
+ args: config.args ?? [],
132
+ ...(config.env ? { env: config.env } : {}),
133
+ ...(config.cwd ?? sessionCwd
134
+ ? { cwd: config.cwd ?? sessionCwd }
135
+ : {}),
136
+ };
114
137
  }
115
138
 
116
139
  function bridgeTool(
117
- client: Client,
140
+ connection: ConnectedMcpServer,
118
141
  serverName: string,
119
142
  tool: McpToolDefinition,
120
143
  name: string,
@@ -126,39 +149,22 @@ function bridgeTool(
126
149
  tool.description && tool.description.trim().length > 0
127
150
  ? tool.description
128
151
  : `The ${tool.name} tool from the ${serverName} MCP server.`,
129
- parameters: toolParameters(tool.inputSchema),
152
+ parameters: tool.inputSchema,
130
153
  execute: async (_toolCallId, args, signal) => {
131
- const result = await client.callTool(
132
- {
133
- name: tool.name,
134
- arguments: isRecord(args) ? args : {},
135
- },
136
- undefined,
154
+ const result = await connection.callTool(
155
+ tool.name,
156
+ isRecord(args) ? args : {},
137
157
  signal ? { signal } : undefined,
138
158
  );
139
- const content = toToolResultContent(result.content);
140
- if (result.isError) {
141
- throw new Error(
142
- content
143
- .map((item) => item.text ?? "")
144
- .filter(Boolean)
145
- .join("\n") || `${tool.name} failed`,
146
- );
147
- }
148
- return { content };
159
+ return {
160
+ content: toToolResultContent(result.content),
161
+ ...(result.isError === true ? { isError: true } : {}),
162
+ };
149
163
  },
150
164
  };
151
165
  }
152
166
 
153
- function toolParameters(inputSchema: unknown): Record<string, unknown> {
154
- if (isRecord(inputSchema) && inputSchema.type === "object") {
155
- return inputSchema;
156
- }
157
- return { type: "object", properties: {} };
158
- }
159
-
160
- function toToolResultContent(content: unknown): AgentToolResultContent[] {
161
- if (!Array.isArray(content)) return [];
167
+ function toToolResultContent(content: unknown[]): AgentToolResultContent[] {
162
168
  const mapped: AgentToolResultContent[] = [];
163
169
  for (const block of content) {
164
170
  if (!isRecord(block)) continue;
@@ -184,7 +190,7 @@ function toToolResultContent(content: unknown): AgentToolResultContent[] {
184
190
  }
185
191
 
186
192
  function sanitize(name: string): string {
187
- return name.replace(/[^a-zA-Z0-9_-]/g, "_");
193
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_") || "tool";
188
194
  }
189
195
 
190
196
  function uniqueName(name: string, taken: Set<string>): string {
@@ -198,6 +204,10 @@ function uniqueName(name: string, taken: Set<string>): string {
198
204
  return candidate;
199
205
  }
200
206
 
207
+ function errorMessage(error: unknown): string {
208
+ return error instanceof Error ? error.message : String(error);
209
+ }
210
+
201
211
  function isRecord(value: unknown): value is Record<string, unknown> {
202
212
  return typeof value === "object" && value !== null && !Array.isArray(value);
203
213
  }
@@ -572,6 +572,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
572
572
 
573
573
  async [Symbol.asyncDispose](): Promise<void> {
574
574
  this.close();
575
+ await this.onCoreDisposed();
575
576
  }
576
577
 
577
578
  async changeDeviceState(
@@ -634,6 +635,10 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
634
635
  // Optional hook for subclass-owned resources outside the controller.
635
636
  }
636
637
 
638
+ protected async onCoreDisposed(): Promise<void> {
639
+ // Optional hook for awaiting asynchronous cleanup started by onCoreClose().
640
+ }
641
+
637
642
  protected currentOptions(): LettaCodeClientSessionOptions | CreateAgentOptions {
638
643
  return this.mode.options;
639
644
  }
package/src/types.ts CHANGED
@@ -227,6 +227,8 @@ export interface AgentToolResultContent {
227
227
  */
228
228
  export interface AgentToolResult<T> {
229
229
  content: AgentToolResultContent[];
230
+ /** Whether the tool completed with a model-visible error result. */
231
+ isError?: boolean;
230
232
  details?: T;
231
233
  }
232
234
 
@@ -266,27 +268,39 @@ export interface AgentTool<TParams, TResult> {
266
268
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
269
  export type AnyAgentTool = AgentTool<any, unknown>;
268
270
 
269
- /** Environment variable passed to a stdio MCP server. */
270
- export interface McpServerEnvironmentVariable {
271
- name: string;
272
- value: string;
273
- }
274
-
275
- /**
276
- * A stdio MCP server whose tools should be exposed to a Letta Code session.
277
- *
278
- * The environment accepts either the object form commonly used by MCP clients
279
- * or the name/value array used by ACP clients such as Zed.
280
- */
281
- export interface McpServerConfig {
282
- name: string;
271
+ /** A local MCP process connected over stdin/stdout. */
272
+ export interface McpStdioServerConfig {
273
+ type?: "stdio";
283
274
  command: string;
284
275
  args?: string[];
285
- env?: Record<string, string> | McpServerEnvironmentVariable[];
276
+ env?: Record<string, string>;
286
277
  /** Override the session cwd for this server process. */
287
278
  cwd?: string;
288
279
  }
289
280
 
281
+ /** A remote MCP server using Streamable HTTP. */
282
+ export interface McpHttpServerConfig {
283
+ type: "http";
284
+ url: string;
285
+ headers?: Record<string, string>;
286
+ }
287
+
288
+ /** A remote MCP server using the legacy SSE transport. */
289
+ export interface McpSseServerConfig {
290
+ type: "sse";
291
+ url: string;
292
+ headers?: Record<string, string>;
293
+ }
294
+
295
+ /** One MCP server definition. The key in {@link McpServers} supplies its name. */
296
+ export type McpServerConfig =
297
+ | McpStdioServerConfig
298
+ | McpHttpServerConfig
299
+ | McpSseServerConfig;
300
+
301
+ /** MCP servers keyed by the name used in `mcp__<server>__<tool>`. */
302
+ export type McpServers = Record<string, McpServerConfig>;
303
+
290
304
  // ═══════════════════════════════════════════════════════════════
291
305
  // TOP-LEVEL CLIENT TYPES
292
306
  // ═══════════════════════════════════════════════════════════════
@@ -668,10 +682,11 @@ export interface CreateSessionOptions {
668
682
  tools?: AnyAgentTool[];
669
683
 
670
684
  /**
671
- * Stdio MCP servers whose tools execute in the SDK process and are exposed
672
- * through Letta Code's external-tool protocol for this session.
685
+ * Session-scoped MCP servers keyed by server name. Stdio processes and
686
+ * remote HTTP/SSE connections run in the Node SDK process and are exposed
687
+ * through Letta Code's external-tool protocol.
673
688
  */
674
- mcpServers?: McpServerConfig[];
689
+ mcpServers?: McpServers;
675
690
 
676
691
  /**
677
692
  * Max automatic approval-conflict recovery attempts per runTurn() call.
package/src/validation.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  CreateSessionOptions,
9
9
  CreateAgentOptions,
10
10
  MemoryItem,
11
+ McpServers,
11
12
  CreateBlock,
12
13
  SystemPromptPreset,
13
14
  SkillSource,
@@ -173,11 +174,41 @@ function validateReasoningEffort(value: unknown): void {
173
174
  }
174
175
  }
175
176
 
177
+ function validateMcpServers(servers: McpServers | undefined): void {
178
+ if (servers === undefined) return;
179
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
180
+ throw new Error("Invalid mcpServers. Expected an object keyed by server name.");
181
+ }
182
+ for (const [name, config] of Object.entries(servers)) {
183
+ if (name.length === 0) {
184
+ throw new Error("Invalid mcpServers. Server names must be non-empty.");
185
+ }
186
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
187
+ throw new Error(`Invalid MCP server '${name}'. Expected a configuration object.`);
188
+ }
189
+ if (config.type === "http" || config.type === "sse") {
190
+ if (typeof config.url !== "string" || config.url.length === 0) {
191
+ throw new Error(`Invalid MCP server '${name}'. Expected a non-empty url.`);
192
+ }
193
+ continue;
194
+ }
195
+ if (config.type !== undefined && config.type !== "stdio") {
196
+ throw new Error(
197
+ `Invalid MCP server '${name}' type '${String(config.type)}'. Valid values: stdio, http, sse.`,
198
+ );
199
+ }
200
+ if (typeof config.command !== "string" || config.command.length === 0) {
201
+ throw new Error(`Invalid MCP server '${name}'. Expected a non-empty command.`);
202
+ }
203
+ }
204
+ }
205
+
176
206
  /**
177
207
  * Validate CreateSessionOptions (used by createSession and resumeSession).
178
208
  */
179
209
  export function validateCreateSessionOptions(options: CreateSessionOptions): void {
180
210
  validateSkillSources(options.skillSources);
211
+ validateMcpServers(options.mcpServers);
181
212
  validateReasoningEffort(options.reasoningEffort);
182
213
  validateDreamingOptions(options.dreaming);
183
214
  validateApprovalRecoveryOptions(options);