@decocms/mesh-sdk 1.2.1 → 1.2.2

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.
@@ -0,0 +1,96 @@
1
+ import type { AiProviderModel, ProviderId } from "../types/ai-providers";
2
+
3
+ /**
4
+ * Preferred default models for each well-known provider.
5
+ *
6
+ * Each entry is an ordered list of candidate model ID strings — lower indexes
7
+ * have higher priority. The selector first tries exact matches across the full
8
+ * list, then falls back to substring matches in the same priority order.
9
+ */
10
+ export const DEFAULT_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> =
11
+ {
12
+ anthropic: ["claude-sonnet-4-6", "claude-sonnet", "claude"],
13
+ openrouter: [
14
+ "anthropic/claude-opus-4.6",
15
+ "anthropic/claude-4.6-opus",
16
+ "anthropic/claude-sonnet-4-6",
17
+ "anthropic/claude-sonnet",
18
+ "anthropic/claude",
19
+ ],
20
+ deco: [
21
+ "anthropic/claude-haiku-4-5",
22
+ "anthropic/claude-haiku",
23
+ "anthropic/claude",
24
+ ],
25
+ google: ["gemini-3-flash"],
26
+ "claude-code": [
27
+ "claude-code:sonnet",
28
+ "claude-code:opus",
29
+ "claude-code:haiku",
30
+ ],
31
+ };
32
+
33
+ /**
34
+ * Preferred fast/cheap models per provider — used for lightweight tasks
35
+ * like title generation where latency and cost matter more than capability.
36
+ */
37
+ export const FAST_MODEL_PREFERENCES: Partial<Record<ProviderId, string[]>> = {
38
+ anthropic: ["claude-haiku-4-5", "claude-haiku"],
39
+ openrouter: [
40
+ "qwen/qwen3.5-flash",
41
+ "anthropic/claude-haiku-4.5",
42
+ "anthropic/claude-haiku",
43
+ "google/gemini-3-flash",
44
+ ],
45
+ deco: ["qwen/qwen3.5-flash", "anthropic/claude-haiku"],
46
+ google: ["gemini-2.5-flash", "gemini-3-flash"],
47
+ };
48
+
49
+ /**
50
+ * Return the preferred fast model ID for a given provider.
51
+ * Returns the first candidate or `null` if no preference is configured.
52
+ */
53
+ export function getFastModel(providerId: ProviderId): string | null {
54
+ const candidates = FAST_MODEL_PREFERENCES[providerId];
55
+ return candidates?.[0] ?? null;
56
+ }
57
+
58
+ /**
59
+ * Select the best default model from a loaded list for a given provider.
60
+ *
61
+ * Resolution order:
62
+ * 1. Exact `modelId` match — walk candidates in priority order.
63
+ * 2. Substring match — walk candidates in priority order, return the first
64
+ * model whose `modelId` contains the candidate string.
65
+ * 3. First model in the list.
66
+ * 4. `null` if the list is empty.
67
+ *
68
+ * @param models Full model list returned by the provider for this key.
69
+ * @param providerId The provider that owns the key.
70
+ * @param keyId Credential key ID to attach — mirrors what
71
+ * `handleModelSelect` does on explicit user selection.
72
+ */
73
+ export function selectDefaultModel(
74
+ models: AiProviderModel[],
75
+ providerId: ProviderId,
76
+ keyId?: string,
77
+ ): AiProviderModel | null {
78
+ if (models.length === 0) return null;
79
+
80
+ const candidates = DEFAULT_MODEL_PREFERENCES[providerId] ?? [];
81
+
82
+ const withKey = (model: AiProviderModel): AiProviderModel =>
83
+ keyId !== undefined ? { ...model, keyId } : model;
84
+
85
+ for (const candidate of candidates) {
86
+ const exact = models.find((m) => m.modelId === candidate);
87
+ if (exact) return withKey(exact);
88
+ }
89
+
90
+ for (const candidate of candidates) {
91
+ const partial = models.find((m) => m.modelId.includes(candidate));
92
+ if (partial) return withKey(partial);
93
+ }
94
+
95
+ return withKey(models[0] as AiProviderModel);
96
+ }
@@ -35,6 +35,47 @@ function hashServerUrl(url: string): string {
35
35
  return Math.abs(hash).toString(16);
36
36
  }
37
37
 
38
+ /**
39
+ * Override origin for OAuth redirect URIs.
40
+ * Set via `setOAuthRedirectOrigin()` when the browser runs behind a proxy
41
+ * (e.g. tokyo.localhost) that external OAuth servers may not accept.
42
+ */
43
+ let _oauthRedirectOrigin: string | null = null;
44
+
45
+ /**
46
+ * Set a custom origin for OAuth redirect URIs.
47
+ * Call this at app init with the server's internal URL (e.g. http://localhost:3000)
48
+ * so that external OAuth servers accept the redirect URI.
49
+ */
50
+ export function setOAuthRedirectOrigin(origin: string): void {
51
+ _oauthRedirectOrigin = origin;
52
+ }
53
+
54
+ /**
55
+ * Get the origin to use for OAuth redirect URIs.
56
+ * Returns the override if set, otherwise falls back to window.location.origin.
57
+ */
58
+ function getOAuthRedirectOrigin(): string {
59
+ return _oauthRedirectOrigin ?? window.location.origin;
60
+ }
61
+
62
+ /**
63
+ * Check if we're in a local dev environment (localhost or .localhost subdomain).
64
+ */
65
+ function isLocalDev(): boolean {
66
+ try {
67
+ const hostname = window.location.hostname;
68
+ return (
69
+ hostname === "localhost" ||
70
+ hostname.endsWith(".localhost") ||
71
+ hostname === "127.0.0.1" ||
72
+ hostname === "::1"
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
38
79
  /**
39
80
  * Global in-memory store for active OAuth sessions.
40
81
  */
@@ -89,7 +130,7 @@ class McpOAuthProvider implements OAuthClientProvider {
89
130
  constructor(options: McpOAuthProviderOptions) {
90
131
  this.serverUrl = options.serverUrl;
91
132
  this._redirectUrl =
92
- options.callbackUrl ?? `${window.location.origin}/oauth/callback`;
133
+ options.callbackUrl ?? `${getOAuthRedirectOrigin()}/oauth/callback`;
93
134
  this._windowMode = options.windowMode ?? "popup";
94
135
 
95
136
  // Build scope string if provided
@@ -153,8 +194,7 @@ class McpOAuthProvider implements OAuthClientProvider {
153
194
  // Open in new tab - uses localStorage for cross-tab communication
154
195
  const tab = window.open(authorizationUrl.toString(), "_blank");
155
196
  if (!tab) {
156
- // Fallback: navigate current window (will lose state, but works)
157
- window.location.href = authorizationUrl.toString();
197
+ throw new Error("Tab was blocked");
158
198
  }
159
199
  } else {
160
200
  // Open in popup (default)
@@ -170,9 +210,11 @@ class McpOAuthProvider implements OAuthClientProvider {
170
210
  );
171
211
 
172
212
  if (!popup) {
173
- throw new Error(
174
- "OAuth popup was blocked. Please allow popups for this site and try again.",
175
- );
213
+ // Popup was blocked - fallback to new tab (uses localStorage for communication)
214
+ const tab = window.open(authorizationUrl.toString(), "_blank");
215
+ if (!tab) {
216
+ throw new Error("Popup was blocked");
217
+ }
176
218
  }
177
219
  }
178
220
  }
@@ -273,6 +315,10 @@ export async function authenticateMcp(params: {
273
315
  windowMode: params.windowMode,
274
316
  });
275
317
 
318
+ // Object to hold the abort function - using an object wrapper so TypeScript
319
+ // properly tracks mutations inside closures
320
+ const oauthAbort: { fn: ((error: Error) => void) | null } = { fn: null };
321
+
276
322
  try {
277
323
  // Wait for OAuth callback message from popup and handle token exchange
278
324
  // Uses both postMessage (primary) and localStorage (fallback for when opener is lost)
@@ -300,6 +346,14 @@ export async function authenticateMcp(params: {
300
346
  }
301
347
  };
302
348
 
349
+ // Expose abort function so we can clean up if auth() throws
350
+ oauthAbort.fn = (error: Error) => {
351
+ if (resolved) return;
352
+ resolved = true;
353
+ cleanup();
354
+ reject(error);
355
+ };
356
+
303
357
  const processCallback = async (data: {
304
358
  success: boolean;
305
359
  code?: string;
@@ -379,7 +433,9 @@ export async function authenticateMcp(params: {
379
433
 
380
434
  // Primary: Listen for postMessage from popup
381
435
  const handleMessage = async (event: MessageEvent) => {
382
- if (event.origin !== window.location.origin) return;
436
+ // In local dev, accept messages from any origin because the popup
437
+ // runs at localhost:PORT while the opener may be at *.localhost (proxy)
438
+ if (!isLocalDev() && event.origin !== window.location.origin) return;
383
439
  if (event.data?.type === "mcp:oauth:callback") {
384
440
  await processCallback(event.data);
385
441
  }
@@ -408,6 +464,10 @@ export async function authenticateMcp(params: {
408
464
  },
409
465
  );
410
466
 
467
+ // Attach a no-op catch to prevent unhandled rejection if auth() throws
468
+ // (we'll abort the promise properly in the catch block, but this is a safety net)
469
+ oauthCompletePromise.catch(() => {});
470
+
411
471
  // Start the auth flow
412
472
  const result: AuthResult = await auth(provider, { serverUrl });
413
473
 
@@ -450,6 +510,11 @@ export async function authenticateMcp(params: {
450
510
  error: null,
451
511
  };
452
512
  } catch (error) {
513
+ // Abort the OAuth promise to trigger cleanup (clear timeout, remove event listeners)
514
+ // This prevents unhandled promise rejections and lingering listeners
515
+ if (oauthAbort.fn) {
516
+ oauthAbort.fn(error instanceof Error ? error : new Error(String(error)));
517
+ }
453
518
  return {
454
519
  token: null,
455
520
  tokenInfo: null,
@@ -477,7 +542,10 @@ function sendCallbackData(
477
542
  ): boolean {
478
543
  // Try postMessage first (primary method)
479
544
  if (window.opener && !window.opener.closed) {
480
- window.opener.postMessage(data, window.location.origin);
545
+ // In local dev, use "*" because the popup (localhost:PORT) and opener
546
+ // (*.localhost proxy) are different origins — targeted postMessage would be silently dropped
547
+ const targetOrigin = isLocalDev() ? "*" : window.location.origin;
548
+ window.opener.postMessage(data, targetOrigin);
481
549
  return true;
482
550
  }
483
551
 
@@ -638,8 +706,11 @@ async function checkOAuthTokenStatus(
638
706
  try {
639
707
  const path = `/api/connections/${connectionId}/oauth-token/status`;
640
708
  const url = apiBaseUrl ? new URL(path, apiBaseUrl).href : path;
709
+ const currentOrigin = getCurrentOrigin();
710
+ const isSameOrigin =
711
+ !apiBaseUrl || new URL(apiBaseUrl).origin === currentOrigin;
641
712
  const response = await fetch(url, {
642
- credentials: apiBaseUrl ? "omit" : "include", // Don't send cookies for cross-origin
713
+ credentials: isSameOrigin ? "include" : "omit", // Don't send cookies for cross-origin
643
714
  });
644
715
  if (!response.ok) {
645
716
  return { hasToken: false };
@@ -57,6 +57,7 @@ export const KEYS = {
57
57
  ) => ["mcp", "client", orgId, connectionId, token, meshUrl] as const,
58
58
 
59
59
  // MCP client-based queries (scoped by client instance)
60
+ // Note: client can be null/undefined for skip queries that shouldn't execute
60
61
  mcpToolsList: (client: unknown) =>
61
62
  ["mcp", "client", client, "tools"] as const,
62
63
  mcpResourcesList: (client: unknown) =>
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Server-Client Bridge
3
+ *
4
+ * Creates an MCP Server that delegates all requests to an MCP Client.
5
+ * This allows using a Client as if it were a Server, useful for proxying
6
+ * or bridging between different transport layers.
7
+ *
8
+ * ## Usage
9
+ *
10
+ * ```ts
11
+ * import { createServerFromClient } from "@decocms/mesh-sdk";
12
+ * import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ * import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
14
+ *
15
+ * const client = new Client(...);
16
+ * await client.connect(clientTransport);
17
+ *
18
+ * const server = createServerFromClient(
19
+ * client,
20
+ * { name: "proxy-server", version: "1.0.0" }
21
+ * );
22
+ *
23
+ * const transport = new WebStandardStreamableHTTPServerTransport({});
24
+ * await server.connect(transport);
25
+ *
26
+ * // Handle requests via transport.handleRequest(req)
27
+ * ```
28
+ */
29
+
30
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
31
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
+ import type {
33
+ Implementation,
34
+ ServerCapabilities,
35
+ } from "@modelcontextprotocol/sdk/types.js";
36
+ import {
37
+ CallToolRequestSchema,
38
+ GetPromptRequestSchema,
39
+ ListPromptsRequestSchema,
40
+ ListResourcesRequestSchema,
41
+ ListResourceTemplatesRequestSchema,
42
+ ListToolsRequestSchema,
43
+ ReadResourceRequestSchema,
44
+ } from "@modelcontextprotocol/sdk/types.js";
45
+
46
+ /**
47
+ * Options for creating a server from a client
48
+ */
49
+ export interface ServerFromClientOptions {
50
+ /**
51
+ * Server capabilities. If not provided, uses client.getServerCapabilities()
52
+ */
53
+ capabilities?: ServerCapabilities;
54
+ /**
55
+ * Server instructions. If not provided, uses client.getInstructions()
56
+ */
57
+ instructions?: string;
58
+ /**
59
+ * Timeout in milliseconds for tool calls forwarded to the client.
60
+ * If not provided, the MCP SDK default (60s) is used.
61
+ */
62
+ toolCallTimeoutMs?: number;
63
+ }
64
+
65
+ /**
66
+ * Creates an MCP Server that delegates all requests to the provided Client.
67
+ *
68
+ * @param client - The MCP Client to delegate requests to
69
+ * @param serverInfo - Server metadata (ImplementationSchema-compatible: name, version, title, description, icons, websiteUrl)
70
+ * @param options - Optional server configuration (capabilities and instructions)
71
+ * @returns An MCP Server instance configured to delegate to the client
72
+ */
73
+ export function createServerFromClient(
74
+ client: Client,
75
+ serverInfo: Implementation,
76
+ options?: ServerFromClientOptions,
77
+ ): McpServer {
78
+ // Get capabilities from client if not provided
79
+ const capabilities = options?.capabilities ?? client.getServerCapabilities();
80
+
81
+ // Get instructions from client if not provided
82
+ const instructions = options?.instructions ?? client.getInstructions();
83
+
84
+ // Create MCP server with capabilities and instructions
85
+ const server = new McpServer(serverInfo, {
86
+ capabilities,
87
+ instructions,
88
+ });
89
+
90
+ // Set up request handlers that delegate to client methods
91
+
92
+ // Tools handlers
93
+ // Strip outputSchema from tools so downstream clients (e.g. the browser's
94
+ // MCP Client) don't cache validators and reject structuredContent that
95
+ // doesn't perfectly match the downstream server's declared schema.
96
+ // A proxy should pass through responses as-is — validation is the
97
+ // responsibility of the originating server, not intermediaries.
98
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => {
99
+ const result = await client.listTools();
100
+ return {
101
+ ...result,
102
+ tools: result.tools.map(({ outputSchema: _, ...tool }) => tool),
103
+ };
104
+ });
105
+
106
+ server.server.setRequestHandler(CallToolRequestSchema, (request) =>
107
+ client.callTool(
108
+ request.params,
109
+ undefined,
110
+ options?.toolCallTimeoutMs
111
+ ? { timeout: options.toolCallTimeoutMs }
112
+ : undefined,
113
+ ),
114
+ );
115
+
116
+ // Resources handlers (only if capabilities include resources)
117
+ if (capabilities?.resources) {
118
+ server.server.setRequestHandler(ListResourcesRequestSchema, () =>
119
+ client.listResources(),
120
+ );
121
+
122
+ server.server.setRequestHandler(ReadResourceRequestSchema, (request) =>
123
+ client.readResource(request.params),
124
+ );
125
+
126
+ server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () =>
127
+ client.listResourceTemplates(),
128
+ );
129
+ }
130
+
131
+ // Prompts handlers (only if capabilities include prompts)
132
+ if (capabilities?.prompts) {
133
+ server.server.setRequestHandler(ListPromptsRequestSchema, () =>
134
+ client.listPrompts(),
135
+ );
136
+
137
+ server.server.setRequestHandler(GetPromptRequestSchema, (request) =>
138
+ client.getPrompt({
139
+ ...request.params,
140
+ arguments: request.params.arguments ?? {},
141
+ }),
142
+ );
143
+ }
144
+
145
+ return server;
146
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Usage utilities tests
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ addUsage,
8
+ calculateUsageStats,
9
+ emptyUsageStats,
10
+ sanitizeProviderMetadata,
11
+ } from "./usage";
12
+
13
+ describe("sanitizeProviderMetadata", () => {
14
+ test("allows only safe fields", () => {
15
+ const metadata = {
16
+ openrouter: {
17
+ usage: { inputTokens: 10, outputTokens: 20, cost: 0.001 },
18
+ cost: 0.001,
19
+ model: "gpt-4",
20
+ internal_id: "should-be-stripped",
21
+ debug_info: "should-be-stripped",
22
+ },
23
+ };
24
+
25
+ const result = sanitizeProviderMetadata(metadata);
26
+
27
+ expect(result).toEqual({
28
+ openrouter: {
29
+ usage: { inputTokens: 10, outputTokens: 20, cost: 0.001 },
30
+ cost: 0.001,
31
+ model: "gpt-4",
32
+ },
33
+ });
34
+ });
35
+
36
+ test("strips sensitive fields", () => {
37
+ const metadata = {
38
+ provider: {
39
+ api_key: "secret",
40
+ user_id: "user_123",
41
+ usage: { totalTokens: 100 },
42
+ },
43
+ };
44
+
45
+ const result = sanitizeProviderMetadata(metadata);
46
+
47
+ expect(result).toEqual({
48
+ provider: {
49
+ usage: { totalTokens: 100 },
50
+ },
51
+ });
52
+ });
53
+
54
+ test("handles nested objects", () => {
55
+ const metadata = {
56
+ openrouter: {
57
+ usage: { inputTokens: 5, outputTokens: 10 },
58
+ nested: { sensitive: "data" },
59
+ },
60
+ };
61
+
62
+ const result = sanitizeProviderMetadata(metadata);
63
+
64
+ expect(result).toEqual({
65
+ openrouter: {
66
+ usage: { inputTokens: 5, outputTokens: 10 },
67
+ },
68
+ });
69
+ });
70
+
71
+ test("returns undefined for empty input", () => {
72
+ expect(sanitizeProviderMetadata(undefined)).toBeUndefined();
73
+ expect(sanitizeProviderMetadata({})).toBeUndefined();
74
+ });
75
+
76
+ test("handles non-object provider data", () => {
77
+ const metadata = {
78
+ provider: "string-value",
79
+ other: null,
80
+ };
81
+
82
+ const result = sanitizeProviderMetadata(metadata);
83
+
84
+ expect(result).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("calculateUsageStats", () => {
89
+ test("sums message-level usage correctly", () => {
90
+ const messages = [
91
+ {
92
+ metadata: {
93
+ usage: { totalTokens: 1000, inputTokens: 500, outputTokens: 500 },
94
+ },
95
+ },
96
+ {
97
+ metadata: {
98
+ usage: { totalTokens: 500, inputTokens: 200, outputTokens: 300 },
99
+ },
100
+ },
101
+ ];
102
+
103
+ const result = calculateUsageStats(messages);
104
+
105
+ expect(result.totalTokens).toBe(1500);
106
+ expect(result.inputTokens).toBe(700);
107
+ expect(result.outputTokens).toBe(800);
108
+ });
109
+
110
+ test("handles missing metadata gracefully", () => {
111
+ const messages = [
112
+ { metadata: { usage: { totalTokens: 100 } } },
113
+ { metadata: {} },
114
+ { metadata: undefined },
115
+ {},
116
+ ];
117
+
118
+ const result = calculateUsageStats(messages);
119
+
120
+ expect(result.totalTokens).toBe(100);
121
+ });
122
+
123
+ test("returns empty stats for empty messages", () => {
124
+ const result = calculateUsageStats([]);
125
+
126
+ expect(result).toEqual(emptyUsageStats());
127
+ });
128
+ });
129
+
130
+ describe("addUsage", () => {
131
+ test("adds usage fields correctly", () => {
132
+ const acc = emptyUsageStats();
133
+ const step = {
134
+ inputTokens: 100,
135
+ outputTokens: 200,
136
+ reasoningTokens: 50,
137
+ totalTokens: 350,
138
+ };
139
+
140
+ const result = addUsage(acc, step);
141
+
142
+ expect(result.inputTokens).toBe(100);
143
+ expect(result.outputTokens).toBe(200);
144
+ expect(result.reasoningTokens).toBe(50);
145
+ expect(result.totalTokens).toBe(350);
146
+ });
147
+
148
+ test("handles undefined fields", () => {
149
+ const acc = { ...emptyUsageStats(), inputTokens: 10 };
150
+ const step = { outputTokens: 20 };
151
+
152
+ const result = addUsage(acc, step);
153
+
154
+ expect(result.inputTokens).toBe(10);
155
+ expect(result.outputTokens).toBe(20);
156
+ });
157
+
158
+ test("returns accumulated when step is null", () => {
159
+ const acc = { ...emptyUsageStats(), totalTokens: 100 };
160
+ const result = addUsage(acc, null);
161
+ expect(result).toBe(acc);
162
+ });
163
+ });