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

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 ADDED
@@ -0,0 +1,203 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import {
3
+ getDefaultEnvironment,
4
+ StdioClientTransport,
5
+ } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import type {
7
+ ConnectMcpServersOptions,
8
+ McpToolBridge,
9
+ } from "./mcp-runtime.js";
10
+ import type {
11
+ AgentToolResultContent,
12
+ AnyAgentTool,
13
+ McpServerConfig,
14
+ } from "./types.js";
15
+
16
+ const EMPTY_BRIDGE: McpToolBridge = {
17
+ tools: [],
18
+ close: async () => undefined,
19
+ };
20
+
21
+ const CLIENT_INFO = {
22
+ name: "@letta-ai/letta-agent-sdk",
23
+ version: "1",
24
+ };
25
+
26
+ /**
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.
30
+ */
31
+ export async function connectMcpServers(
32
+ servers: readonly McpServerConfig[] | undefined,
33
+ options: ConnectMcpServersOptions = {},
34
+ ): Promise<McpToolBridge> {
35
+ if (!servers || servers.length === 0) return EMPTY_BRIDGE;
36
+
37
+ const log = options.log ?? ((message: string) => console.error(message));
38
+ const clients: Client[] = [];
39
+ const tools: AnyAgentTool[] = [];
40
+ const taken = new Set(options.reservedToolNames ?? []);
41
+
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
+ }),
57
+ );
58
+
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"})`,
72
+ );
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)}`);
78
+ }
79
+ }
80
+
81
+ if (clients.length === 0) return EMPTY_BRIDGE;
82
+
83
+ let closed = false;
84
+ return {
85
+ tools,
86
+ close: async () => {
87
+ if (closed) return;
88
+ closed = true;
89
+ await Promise.all(
90
+ clients.map((client) => client.close().catch(() => undefined)),
91
+ );
92
+ },
93
+ };
94
+ }
95
+
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;
105
+ }
106
+ return result;
107
+ }
108
+
109
+ interface McpToolDefinition {
110
+ name: string;
111
+ title?: string;
112
+ description?: string;
113
+ inputSchema?: unknown;
114
+ }
115
+
116
+ function bridgeTool(
117
+ client: Client,
118
+ serverName: string,
119
+ tool: McpToolDefinition,
120
+ name: string,
121
+ ): AnyAgentTool {
122
+ return {
123
+ name,
124
+ label: tool.title ?? tool.name,
125
+ description:
126
+ tool.description && tool.description.trim().length > 0
127
+ ? tool.description
128
+ : `The ${tool.name} tool from the ${serverName} MCP server.`,
129
+ parameters: toolParameters(tool.inputSchema),
130
+ execute: async (_toolCallId, args, signal) => {
131
+ const result = await client.callTool(
132
+ {
133
+ name: tool.name,
134
+ arguments: isRecord(args) ? args : {},
135
+ },
136
+ undefined,
137
+ signal ? { signal } : undefined,
138
+ );
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 };
149
+ },
150
+ };
151
+ }
152
+
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 [];
162
+ const mapped: AgentToolResultContent[] = [];
163
+ for (const block of content) {
164
+ if (!isRecord(block)) continue;
165
+ if (block.type === "text" && typeof block.text === "string") {
166
+ mapped.push({ type: "text", text: block.text });
167
+ continue;
168
+ }
169
+ if (
170
+ block.type === "image" &&
171
+ typeof block.data === "string" &&
172
+ typeof block.mimeType === "string"
173
+ ) {
174
+ mapped.push({
175
+ type: "image",
176
+ data: block.data,
177
+ mimeType: block.mimeType,
178
+ });
179
+ continue;
180
+ }
181
+ mapped.push({ type: "text", text: JSON.stringify(block) });
182
+ }
183
+ return mapped;
184
+ }
185
+
186
+ function sanitize(name: string): string {
187
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_");
188
+ }
189
+
190
+ function uniqueName(name: string, taken: Set<string>): string {
191
+ let candidate = name;
192
+ let suffix = 2;
193
+ while (taken.has(candidate)) {
194
+ candidate = `${name}_${suffix}`;
195
+ suffix += 1;
196
+ }
197
+ taken.add(candidate);
198
+ return candidate;
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
@@ -92,6 +92,8 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
92
92
  this.turns = new RemoteTurnCoordinator({
93
93
  label: config.label,
94
94
  requestTimeoutMs: config.requestTimeoutMs,
95
+ autoHandlesToolApprovals:
96
+ mode.kind === "session" && typeof mode.options.canUseTool === "function",
95
97
  onDeviceStatus: (status) => this.emitDeviceStatus(status),
96
98
  });
97
99
  }
@@ -35,6 +35,7 @@ import {
35
35
  type RemoteTurnCoordinatorConfig = {
36
36
  label: string;
37
37
  requestTimeoutMs?: number;
38
+ autoHandlesToolApprovals?: boolean;
38
39
  onDeviceStatus(status: SessionDeviceStatus): void;
39
40
  };
40
41
 
@@ -48,6 +49,7 @@ type RemoteTurnCoordinatorConfig = {
48
49
  export class RemoteTurnCoordinator {
49
50
  private readonly label: string;
50
51
  private readonly requestTimeoutMs: number | undefined;
52
+ private readonly autoHandlesToolApprovals: boolean;
51
53
  private readonly onDeviceStatus: (status: SessionDeviceStatus) => void;
52
54
  private streamQueue: SDKMessage[] = [];
53
55
  private streamResolvers: Array<(message: SDKMessage | null) => void> = [];
@@ -62,6 +64,7 @@ export class RemoteTurnCoordinator {
62
64
  constructor(config: RemoteTurnCoordinatorConfig) {
63
65
  this.label = config.label;
64
66
  this.requestTimeoutMs = config.requestTimeoutMs;
67
+ this.autoHandlesToolApprovals = config.autoHandlesToolApprovals === true;
65
68
  this.onDeviceStatus = config.onDeviceStatus;
66
69
  }
67
70
 
@@ -239,6 +242,9 @@ export class RemoteTurnCoordinator {
239
242
  active.observedTurnEvidence || active.observedRequiresApprovalStop;
240
243
  if (!hadTurnEvidence) return;
241
244
  if (status === "WAITING_ON_APPROVAL") {
245
+ // Loop status and approval requests arrive on different sockets. Keep a
246
+ // callback-backed turn open until its control request continues the turn.
247
+ if (this.autoHandlesToolApprovals) return;
242
248
  this.completeActiveTurn({
243
249
  runtime: active.runtime,
244
250
  stopReason: "requires_approval",
package/src/types.ts CHANGED
@@ -266,6 +266,27 @@ export interface AgentTool<TParams, TResult> {
266
266
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
267
  export type AnyAgentTool = AgentTool<any, unknown>;
268
268
 
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;
283
+ command: string;
284
+ args?: string[];
285
+ env?: Record<string, string> | McpServerEnvironmentVariable[];
286
+ /** Override the session cwd for this server process. */
287
+ cwd?: string;
288
+ }
289
+
269
290
  // ═══════════════════════════════════════════════════════════════
270
291
  // TOP-LEVEL CLIENT TYPES
271
292
  // ═══════════════════════════════════════════════════════════════
@@ -646,6 +667,12 @@ export interface CreateSessionOptions {
646
667
  */
647
668
  tools?: AnyAgentTool[];
648
669
 
670
+ /**
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.
673
+ */
674
+ mcpServers?: McpServerConfig[];
675
+
649
676
  /**
650
677
  * Max automatic approval-conflict recovery attempts per runTurn() call.
651
678
  * Set to 0 to disable automatic recovery.