@mcp-ts/sdk 1.3.9 → 1.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-ts/sdk",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,7 +10,11 @@ export { useMcp, type UseMcpOptions, type McpClient, type McpConnection } from '
10
10
  export { useAppHost } from './use-app-host.js';
11
11
 
12
12
  // Simplified MCP Apps Hook - the main API
13
- export { useMcpApps } from './use-mcp-apps.js';
13
+ export {
14
+ useMcpApps,
15
+ type McpAppRendererProps,
16
+ type McpAppMetadata,
17
+ } from './use-mcp-apps.js';
14
18
 
15
19
  // Re-export shared types and client from main entry
16
20
  export * from '../index.js';
@@ -5,10 +5,11 @@ import { AppHost } from '../core/app-host';
5
5
  /**
6
6
  * Hook to host an MCP App in a React component
7
7
  *
8
- * Optimized for instant loading:
9
- * - Creates AppHost synchronously
10
- * - Starts bridge connection immediately
11
- * - Returns host before connection completes (ready to call launch)
8
+ * Initialization is async but optimized for instant availability:
9
+ * - Constructor runs synchronously (sandbox + bridge handler setup)
10
+ * - Host is set in state immediately so launch() can be called right away
11
+ * - start() is a lightweight no-op reserved for future async pre-init work
12
+ * - The real async work (iframe load, bridge connect) happens inside launch()
12
13
  *
13
14
  * @param client - Connected SSEClient instance
14
15
  * @param iframeRef - Reference to the iframe element
@@ -4,7 +4,15 @@
4
4
  * Provides utilities for rendering interactive UI components from MCP servers.
5
5
  */
6
6
 
7
- import React, { useState, useEffect, useCallback, useRef, memo } from 'react';
7
+ import React, {
8
+ useState,
9
+ useEffect,
10
+ useCallback,
11
+ useRef,
12
+ memo,
13
+ useMemo,
14
+ type MutableRefObject,
15
+ } from 'react';
8
16
  import { useAppHost } from './use-app-host.js';
9
17
  import type { SSEClient } from '../core/sse-client.js';
10
18
 
@@ -33,8 +41,8 @@ export interface McpAppMetadata {
33
41
  sessionId: string;
34
42
  }
35
43
 
36
- interface McpAppRendererProps {
37
- mcpClient: McpClient | null;
44
+ /** Props for {@link useMcpApps}'s `McpAppRenderer` (client is supplied via the hook). */
45
+ export interface McpAppRendererProps {
38
46
  name: string;
39
47
  input?: Record<string, unknown>;
40
48
  result?: unknown;
@@ -43,90 +51,68 @@ interface McpAppRendererProps {
43
51
  className?: string;
44
52
  }
45
53
 
46
- /**
47
- * Simplified MCP App renderer - users just pass tool name and data
48
- * Internal hook handles metadata lookup and SSE client retrieval
49
- */
50
- const McpAppRenderer = memo(function McpAppRenderer({
51
- mcpClient,
54
+ type McpAppViewProps = McpAppRendererProps & {
55
+ /**
56
+ * Ref avoids tying `McpAppRenderer` identity to `mcpClient`: when `connections` updates, `useMcp()` still
57
+ * returns a new object (correct for `useEffect` deps), but the iframe must not remount.
58
+ */
59
+ clientRef: MutableRefObject<McpClient | null>;
60
+ };
61
+
62
+ /** Renders one MCP App in a sandboxed iframe; reads the latest client from `clientRef` each render. */
63
+ const McpAppView = memo(function McpAppView({
64
+ clientRef,
52
65
  name,
53
66
  input,
54
67
  result,
55
68
  status,
56
69
  className,
57
- }: McpAppRendererProps) {
58
- const getAppMetadata = useCallback((): McpAppMetadata | undefined => {
59
- if (!mcpClient) return undefined;
60
-
61
- const extractedName = extractToolName(name);
62
-
63
- for (const conn of mcpClient.connections) {
64
- for (const tool of conn.tools) {
65
- const candidateName = extractToolName(tool.name);
66
- const resourceUri =
67
- tool.mcpApp?.resourceUri ??
68
- tool._meta?.ui?.resourceUri ??
69
- tool._meta?.['ui/resourceUri'];
70
-
71
- if (resourceUri && candidateName === extractedName) {
72
- return {
73
- toolName: candidateName,
74
- resourceUri,
75
- sessionId: conn.sessionId,
76
- };
77
- }
78
- }
79
- }
80
-
81
- return undefined;
82
- }, [mcpClient, name]);
83
-
84
- const metadata = getAppMetadata();
70
+ }: McpAppViewProps) {
71
+ const mcpClient = clientRef.current;
72
+ const metadata = getMcpAppMetadata(mcpClient, name);
85
73
  const sseClient = mcpClient?.sseClient ?? null;
74
+ const resourceUri = metadata?.resourceUri;
75
+ const appSessionId = metadata?.sessionId;
86
76
 
87
- if (!metadata || !sseClient) {
88
- return null;
89
- }
90
77
  const iframeRef = useRef<HTMLIFrameElement>(null);
91
78
  const { host, error: hostError } = useAppHost(sseClient as SSEClient, iframeRef);
92
79
  const [isLaunched, setIsLaunched] = useState(false);
93
80
  const [error, setError] = useState<Error | null>(null);
94
81
 
95
- // Track which data has been sent to prevent duplicates
96
82
  const sentInputRef = useRef(false);
97
83
  const sentResultRef = useRef(false);
98
84
  const lastInputRef = useRef(input);
99
85
  const lastResultRef = useRef(result);
100
86
  const lastStatusRef = useRef(status);
101
87
 
102
- // Launch the app when host is ready
103
88
  useEffect(() => {
104
- if (!host || !metadata.resourceUri || !metadata.sessionId) return;
89
+ setIsLaunched(false);
90
+ setError(null);
91
+ }, [resourceUri, appSessionId]);
92
+
93
+ useEffect(() => {
94
+ if (!host || !resourceUri || !appSessionId) return;
105
95
 
106
96
  host
107
- .launch(metadata.resourceUri, metadata.sessionId)
97
+ .launch(resourceUri, appSessionId)
108
98
  .then(() => setIsLaunched(true))
109
99
  .catch((err) => setError(err instanceof Error ? err : new Error(String(err))));
110
- }, [host, metadata.resourceUri, metadata.sessionId]);
100
+ }, [host, resourceUri, appSessionId]);
111
101
 
112
- // Send tool input when available or when it changes
113
102
  useEffect(() => {
114
- if (!host || !isLaunched || !input) return;
115
-
116
- // Send if never sent, or if input changed
103
+ if (!host || !isLaunched || !resourceUri || !appSessionId || !input) return;
104
+
117
105
  if (!sentInputRef.current || JSON.stringify(input) !== JSON.stringify(lastInputRef.current)) {
118
106
  sentInputRef.current = true;
119
107
  lastInputRef.current = input;
120
108
  host.sendToolInput(input);
121
109
  }
122
- }, [host, isLaunched, input]);
110
+ }, [host, isLaunched, input, resourceUri, appSessionId, name]);
123
111
 
124
- // Send tool result when complete or when it changes
125
112
  useEffect(() => {
126
- if (!host || !isLaunched || result === undefined) return;
113
+ if (!host || !isLaunched || !resourceUri || !appSessionId || result === undefined) return;
127
114
  if (status !== 'complete') return;
128
115
 
129
- // Send if never sent, or if result changed
130
116
  if (!sentResultRef.current || JSON.stringify(result) !== JSON.stringify(lastResultRef.current)) {
131
117
  sentResultRef.current = true;
132
118
  lastResultRef.current = result;
@@ -136,9 +122,8 @@ const McpAppRenderer = memo(function McpAppRenderer({
136
122
  : result;
137
123
  host.sendToolResult(formattedResult);
138
124
  }
139
- }, [host, isLaunched, result, status]);
125
+ }, [host, isLaunched, result, status, resourceUri, appSessionId, name]);
140
126
 
141
- // Reset sent flags when tool status resets to executing (new tool call)
142
127
  useEffect(() => {
143
128
  if (status === 'executing' && lastStatusRef.current !== 'executing') {
144
129
  sentInputRef.current = false;
@@ -147,7 +132,10 @@ const McpAppRenderer = memo(function McpAppRenderer({
147
132
  lastStatusRef.current = status;
148
133
  }, [status]);
149
134
 
150
- // Display errors
135
+ if (!metadata || !sseClient) {
136
+ return null;
137
+ }
138
+
151
139
  const displayError = error || hostError;
152
140
  if (displayError) {
153
141
  return (
@@ -176,72 +164,61 @@ const McpAppRenderer = memo(function McpAppRenderer({
176
164
  });
177
165
 
178
166
  /**
179
- * Simple hook to get MCP app metadata
180
- *
181
- * @param mcpClient - The MCP client from useMcp() or context
182
- * @returns Object with getAppMetadata function and McpAppRenderer component
183
- *
184
- * @example
185
- * ```tsx
186
- * function ToolRenderer(props) {
187
- * const { getAppMetadata, McpAppRenderer } = useMcpApps(mcpClient);
188
- * const metadata = getAppMetadata(props.name);
167
+ * Helpers scoped to one `mcpClient`. Pass the client here once; `McpAppRenderer` only needs per-tool props (`name`, `input`, `result`, `status`).
189
168
  *
190
- * if (!metadata) return null;
191
- * return (
192
- * <McpAppRenderer
193
- * metadata={metadata}
194
- * input={props.args}
195
- * result={props.result}
196
- * status={props.status}
197
- * />
198
- * );
199
- * }
200
- * ```
169
+ * @param mcpClient - From `useMcp()` or context (for example `useMcpContext()`).
201
170
  */
202
171
  export function useMcpApps(mcpClient: McpClient | null) {
203
- /**
204
- * Get MCP app metadata for a tool name
205
- * This is fast and can be called on every render
206
- */
207
- const getAppMetadata = useCallback(
208
- (toolName: string): McpAppMetadata | undefined => {
209
- if (!mcpClient) return undefined;
210
-
211
- const extractedName = extractToolName(toolName);
212
-
213
- for (const conn of mcpClient.connections) {
214
- for (const tool of conn.tools) {
215
- const candidateName = extractToolName(tool.name);
216
- // Check both locations: direct mcpApp or _meta.ui
217
- const resourceUri =
218
- tool.mcpApp?.resourceUri ??
219
- tool._meta?.ui?.resourceUri ??
220
- tool._meta?.['ui/resourceUri'];
221
-
222
- if (resourceUri && candidateName === extractedName) {
223
- return {
224
- toolName: candidateName,
225
- resourceUri,
226
- sessionId: conn.sessionId,
227
- };
228
- }
229
- }
230
- }
172
+ // Stable `McpAppRenderer` type: parent re-renders and `connections` updates must not remount the iframe.
173
+ const clientRef = useRef(mcpClient);
174
+ clientRef.current = mcpClient;
231
175
 
232
- return undefined;
233
- },
234
- [mcpClient]
176
+ const getAppMetadata = useCallback(
177
+ (toolName: string) => getMcpAppMetadata(clientRef.current, toolName),
178
+ []
235
179
  );
236
180
 
181
+ const McpAppRenderer = useMemo(() => {
182
+ const Renderer = memo(function McpAppRenderer(props: McpAppRendererProps) {
183
+ return <McpAppView clientRef={clientRef} {...props} />;
184
+ });
185
+ Renderer.displayName = 'McpAppRenderer';
186
+ return Renderer;
187
+ }, []);
188
+
237
189
  return { getAppMetadata, McpAppRenderer };
238
190
  }
239
191
 
240
- /**
241
- * Extract the base tool name, removing any prefixes
242
- */
243
192
  function extractToolName(fullName: string): string {
244
- // Handle patterns like "tool_abc123_get-time" -> "get-time"
245
193
  const match = fullName.match(/(?:tool_[^_]+_)?(.+)$/);
246
194
  return match?.[1] || fullName;
247
195
  }
196
+
197
+ function getMcpAppMetadata(
198
+ mcpClient: McpClient | null,
199
+ toolName: string
200
+ ): McpAppMetadata | undefined {
201
+ if (!mcpClient) return undefined;
202
+
203
+ const extractedName = extractToolName(toolName);
204
+
205
+ for (const conn of mcpClient.connections) {
206
+ for (const tool of conn.tools) {
207
+ const candidateName = extractToolName(tool.name);
208
+ const resourceUri =
209
+ tool.mcpApp?.resourceUri ??
210
+ tool._meta?.ui?.resourceUri ??
211
+ tool._meta?.['ui/resourceUri'];
212
+
213
+ if (resourceUri && candidateName === extractedName) {
214
+ return {
215
+ toolName: candidateName,
216
+ resourceUri,
217
+ sessionId: conn.sessionId,
218
+ };
219
+ }
220
+ }
221
+ }
222
+
223
+ return undefined;
224
+ }
@@ -4,7 +4,7 @@
4
4
  * Based on Cloudflare's agents pattern
5
5
  */
6
6
 
7
- import { useEffect, useRef, useState, useCallback } from 'react';
7
+ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
8
8
  import { SSEClient, type SSEClientOptions } from '../core/sse-client';
9
9
  import type { McpConnectionEvent, McpConnectionState } from '../../shared/events';
10
10
  import type {
@@ -227,6 +227,8 @@ export function useMcp(options: UseMcpOptions): McpClient {
227
227
  'disconnected'
228
228
  );
229
229
  const [isInitializing, setIsInitializing] = useState(false);
230
+ /** Mirrored from `clientRef` so the public `McpClient` object can be memoized when the instance is ready. */
231
+ const [sseClient, setSseClient] = useState<SSEClient | null>(null);
230
232
 
231
233
  /**
232
234
  * Initialize SSE client
@@ -258,6 +260,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
258
260
 
259
261
  const client = new SSEClient(clientOptions);
260
262
  clientRef.current = client;
263
+ setSseClient(client);
261
264
 
262
265
  if (autoConnect) {
263
266
  client.connect();
@@ -615,27 +618,52 @@ export function useMcp(options: UseMcpOptions): McpClient {
615
618
  [getConnection]
616
619
  );
617
620
 
618
- return {
619
- connections,
620
- status,
621
- isInitializing,
622
- connect,
623
- disconnect,
624
- getConnection,
625
- getConnectionByServerId,
626
- isServerConnected,
627
- getTools,
628
- refresh,
629
- connectSSE,
630
- disconnectSSE,
631
- finishAuth,
632
- resumeAuth,
633
- callTool,
634
- listTools,
635
- listPrompts,
636
- getPrompt,
637
- listResources,
638
- readResource,
639
- sseClient: clientRef.current,
640
- };
621
+ return useMemo(
622
+ () => ({
623
+ connections,
624
+ status,
625
+ isInitializing,
626
+ connect,
627
+ disconnect,
628
+ getConnection,
629
+ getConnectionByServerId,
630
+ isServerConnected,
631
+ getTools,
632
+ refresh,
633
+ connectSSE,
634
+ disconnectSSE,
635
+ finishAuth,
636
+ resumeAuth,
637
+ callTool,
638
+ listTools,
639
+ listPrompts,
640
+ getPrompt,
641
+ listResources,
642
+ readResource,
643
+ sseClient,
644
+ }),
645
+ [
646
+ connections,
647
+ status,
648
+ isInitializing,
649
+ connect,
650
+ disconnect,
651
+ getConnection,
652
+ getConnectionByServerId,
653
+ isServerConnected,
654
+ getTools,
655
+ refresh,
656
+ connectSSE,
657
+ disconnectSSE,
658
+ finishAuth,
659
+ resumeAuth,
660
+ callTool,
661
+ listTools,
662
+ listPrompts,
663
+ getPrompt,
664
+ listResources,
665
+ readResource,
666
+ sseClient,
667
+ ]
668
+ );
641
669
  }
@@ -0,0 +1,92 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
+
3
+ const ALGORITHM = 'aes-256-gcm';
4
+ const IV_LENGTH = 12;
5
+ const ENCRYPTION_PREFIX = 'enc:1:';
6
+
7
+ let warningLogged = false;
8
+
9
+ function getKey(): Buffer | null {
10
+ const keyString = process.env.STORAGE_ENCRYPTION_KEY;
11
+ if (!keyString) return null;
12
+
13
+ // Ensure key is 32 bytes (256 bits)
14
+ if (keyString.length === 64) {
15
+ return Buffer.from(keyString, 'hex');
16
+ } else {
17
+ const keyBuffer = Buffer.alloc(32);
18
+ keyBuffer.write(keyString, 0, 32, 'utf-8');
19
+ return keyBuffer;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Encrypts an object into a secure string.
25
+ * Falls back to returning the original object if the encryption key is missing or encryption fails.
26
+ */
27
+ export function encryptObject(data: any): any {
28
+ if (data === undefined || data === null) return data;
29
+
30
+ const key = getKey();
31
+ if (!key) {
32
+ if (!warningLogged) {
33
+ console.warn('[mcp-ts][Storage] WARNING: STORAGE_ENCRYPTION_KEY is not set. Saving sensitive data in plain-text.');
34
+ warningLogged = true;
35
+ }
36
+ return data; // Fallback to plain-text
37
+ }
38
+
39
+ try {
40
+ const text = JSON.stringify(data);
41
+ const iv = randomBytes(IV_LENGTH);
42
+ const cipher = createCipheriv(ALGORITHM, key, iv);
43
+
44
+ let encrypted = cipher.update(text, 'utf-8', 'hex');
45
+ encrypted += cipher.final('hex');
46
+ const authTag = cipher.getAuthTag().toString('hex');
47
+
48
+ return `${ENCRYPTION_PREFIX}${iv.toString('hex')}:${authTag}:${encrypted}`;
49
+ } catch (e) {
50
+ console.error('[mcp-ts][Storage] Encryption failed, falling back to plain-text.', e);
51
+ return data;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Decrypts a secure string back into an object.
57
+ * Returns the original data if it is unencrypted or if decryption fails.
58
+ */
59
+ export function decryptObject(data: any): any {
60
+ if (data === undefined || data === null) return data;
61
+ if (typeof data !== 'string' || !data.startsWith(ENCRYPTION_PREFIX)) {
62
+ return data; // Already unencrypted or old plain-text data
63
+ }
64
+
65
+ const key = getKey();
66
+ if (!key) {
67
+ console.warn('[mcp-ts][Storage] WARNING: Found encrypted data but STORAGE_ENCRYPTION_KEY is missing. Returning raw encrypted string.');
68
+ return data;
69
+ }
70
+
71
+ try {
72
+ const parts = data.split(':');
73
+ if (parts.length !== 5) {
74
+ return data;
75
+ }
76
+
77
+ const iv = Buffer.from(parts[2], 'hex');
78
+ const authTag = Buffer.from(parts[3], 'hex');
79
+ const encryptedText = parts[4];
80
+
81
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
82
+ decipher.setAuthTag(authTag);
83
+
84
+ let decrypted = decipher.update(encryptedText, 'hex', 'utf-8');
85
+ decrypted += decipher.final('utf-8');
86
+
87
+ return JSON.parse(decrypted);
88
+ } catch (e) {
89
+ console.error('[mcp-ts][Storage] Decryption failed.', e);
90
+ return data;
91
+ }
92
+ }
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
2
2
  import { StorageBackend, SessionData } from './types.js';
3
3
  import { SESSION_TTL_SECONDS } from '../../shared/constants.js';
4
4
  import { generateSessionId } from '../../shared/utils.js';
5
+ import { encryptObject, decryptObject } from './crypto.js';
5
6
 
6
7
  export class SupabaseStorageBackend implements StorageBackend {
7
8
  private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
@@ -43,10 +44,10 @@ export class SupabaseStorageBackend implements StorageBackend {
43
44
  callbackUrl: row.callback_url,
44
45
  createdAt: new Date(row.created_at).getTime(),
45
46
  identity: row.identity,
46
- headers: row.headers,
47
+ headers: decryptObject(row.headers),
47
48
  active: row.active,
48
49
  clientInformation: row.client_information,
49
- tokens: row.tokens,
50
+ tokens: decryptObject(row.tokens),
50
51
  codeVerifier: row.code_verifier,
51
52
  clientId: row.client_id,
52
53
  };
@@ -71,10 +72,10 @@ export class SupabaseStorageBackend implements StorageBackend {
71
72
  callback_url: session.callbackUrl,
72
73
  created_at: new Date(session.createdAt || Date.now()).toISOString(),
73
74
  identity: identity,
74
- headers: session.headers,
75
+ headers: encryptObject(session.headers),
75
76
  active: session.active ?? false,
76
77
  client_information: session.clientInformation,
77
- tokens: session.tokens,
78
+ tokens: encryptObject(session.tokens),
78
79
  code_verifier: session.codeVerifier,
79
80
  client_id: session.clientId,
80
81
  expires_at: expiresAt
@@ -105,9 +106,9 @@ export class SupabaseStorageBackend implements StorageBackend {
105
106
  if ('transportType' in data) updateData.transport_type = data.transportType;
106
107
  if ('callbackUrl' in data) updateData.callback_url = data.callbackUrl;
107
108
  if ('active' in data) updateData.active = data.active;
108
- if ('headers' in data) updateData.headers = data.headers;
109
+ if ('headers' in data) updateData.headers = encryptObject(data.headers);
109
110
  if ('clientInformation' in data) updateData.client_information = data.clientInformation;
110
- if ('tokens' in data) updateData.tokens = data.tokens;
111
+ if ('tokens' in data) updateData.tokens = encryptObject(data.tokens);
111
112
  if ('codeVerifier' in data) updateData.code_verifier = data.codeVerifier;
112
113
  if ('clientId' in data) updateData.client_id = data.clientId;
113
114