@dxos/mcp-client 0.8.3 → 0.8.4-main.422d1c7879

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": "@dxos/mcp-client",
3
- "version": "0.8.3",
3
+ "version": "0.8.4-main.422d1c7879",
4
4
  "description": "MCP client.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -25,11 +25,11 @@
25
25
  "dependencies": {
26
26
  "@effect/ai": "0.33.2",
27
27
  "@modelcontextprotocol/sdk": "^1.27.1",
28
- "effect": "3.19.16",
29
- "@dxos/ai": "0.8.3",
30
- "@dxos/effect": "0.8.3",
31
- "@dxos/invariant": "0.8.3",
32
- "@dxos/log": "0.8.3"
28
+ "effect": "3.20.0",
29
+ "@dxos/ai": "0.8.4-main.422d1c7879",
30
+ "@dxos/effect": "0.8.4-main.422d1c7879",
31
+ "@dxos/invariant": "0.8.4-main.422d1c7879",
32
+ "@dxos/log": "0.8.4-main.422d1c7879"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -10,8 +10,9 @@ import * as Effect from 'effect/Effect';
10
10
  import * as Layer from 'effect/Layer';
11
11
  import * as Schema from 'effect/Schema';
12
12
 
13
- import { AiService, type GenericToolkit } from '@dxos/ai';
13
+ import { AiService, type OpaqueToolkit } from '@dxos/ai';
14
14
  import { TestAiService } from '@dxos/ai/testing';
15
+ import { runAndForwardErrors } from '@dxos/effect';
15
16
  import { TestHelpers } from '@dxos/effect/testing';
16
17
  import { log } from '@dxos/log';
17
18
 
@@ -25,6 +26,26 @@ const AiServiceLayer = AiService.model('@anthropic/claude-opus-4-6', { thinking:
25
26
  ),
26
27
  );
27
28
 
29
+ describe('connectWithFallback', () => {
30
+ it(
31
+ 'connects to Linear MCP (SSE kind falls back to HTTP)',
32
+ {
33
+ skip: !process.env.LINEAR_API_KEY,
34
+ timeout: 30_000,
35
+ },
36
+ async () => {
37
+ const toolkit = await runAndForwardErrors(
38
+ McpToolkit.make({
39
+ url: 'https://mcp.linear.app/mcp',
40
+ kind: 'sse',
41
+ apiKey: process.env.LINEAR_API_KEY,
42
+ }),
43
+ );
44
+ log.info('connected', { tools: Object.keys(toolkit.toolkit.tools) });
45
+ },
46
+ );
47
+ });
48
+
28
49
  describe('Browser Automation', () => {
29
50
  it.effect(
30
51
  'smoke',
@@ -37,7 +58,7 @@ describe('Browser Automation', () => {
37
58
 
38
59
  const chat = yield* Chat.empty;
39
60
  let prompt: Prompt.RawInput = 'Check that you are able to use the browser. Open https://example.com';
40
- let output: LanguageModel.GenerateTextResponse<GenericToolkit.GenericTools>;
61
+ let output: LanguageModel.GenerateTextResponse<OpaqueToolkit.OpaqueTools>;
41
62
 
42
63
  do {
43
64
  output = yield* chat
@@ -59,7 +80,9 @@ describe('Browser Automation', () => {
59
80
  TestHelpers.provideTestContext,
60
81
  TestHelpers.taggedTest('llm'),
61
82
  ),
62
- { timeout: 120_000 },
83
+ {
84
+ timeout: 120_000,
85
+ },
63
86
  );
64
87
 
65
88
  it.effect(
@@ -74,7 +97,7 @@ describe('Browser Automation', () => {
74
97
  const chat = yield* Chat.empty;
75
98
  let prompt: Prompt.RawInput =
76
99
  'Scrape effect blog at https://effect.website/blog and find the content of last 3 articles. Next prompt I will ask you generate structured representation.';
77
- let output: LanguageModel.GenerateTextResponse<GenericToolkit.GenericTools>;
100
+ let output: LanguageModel.GenerateTextResponse<OpaqueToolkit.OpaqueTools>;
78
101
 
79
102
  do {
80
103
  output = yield* chat
@@ -118,6 +141,8 @@ describe('Browser Automation', () => {
118
141
  TestHelpers.provideTestContext,
119
142
  TestHelpers.taggedTest('llm'),
120
143
  ),
121
- { timeout: 120_000 },
144
+ {
145
+ timeout: 120_000,
146
+ },
122
147
  );
123
148
  });
package/src/McpToolkit.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ // @import-as-namespace
6
+
5
7
  import * as Tool from '@effect/ai/Tool';
6
8
  import * as Toolkit from '@effect/ai/Toolkit';
7
9
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -10,47 +12,39 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
10
12
  import * as Effect from 'effect/Effect';
11
13
  import * as Schema from 'effect/Schema';
12
14
 
13
- import { GenericToolkit } from '@dxos/ai';
15
+ import { OpaqueToolkit } from '@dxos/ai';
14
16
  import { invariant } from '@dxos/invariant';
15
17
 
16
18
  export interface McpToolkitOptions {
17
19
  url: string;
18
20
  kind: 'sse' | 'http';
21
+ apiKey?: string;
19
22
  }
20
23
 
21
24
  /**
22
- * Creates a GenericToolkit that connects to an MCP server and exposes its tools to the assistant.
25
+ * Creates an OpaqueToolkit that connects to an MCP server and exposes its tools to the assistant.
23
26
  *
24
27
  * @param options MCP server URL and transport kind ('sse' for SSE, 'http' for Streamable HTTP).
25
- * @returns A GenericToolkit containing all tools from the MCP server.
28
+ * @returns An OpaqueToolkit containing all tools from the MCP server.
26
29
  */
27
- export const make = (options: McpToolkitOptions): Effect.Effect<GenericToolkit.GenericToolkit> =>
30
+ const CLIENT_INFO = { name: '@dxos/mcp-client', version: '0.8.3' };
31
+
32
+ export const make = (options: McpToolkitOptions): Effect.Effect<OpaqueToolkit.OpaqueToolkit> =>
28
33
  Effect.gen(function* () {
29
- const transport = createTransport(options.url, options.kind);
30
- const client = new Client({ name: '@dxos/mcp-client', version: '0.8.3' });
31
- yield* Effect.promise(() => client.connect(transport));
34
+ const client = yield* connectWithFallback(options);
32
35
 
33
36
  const { tools } = yield* Effect.promise(() => client.listTools());
34
-
35
37
  if (tools.length === 0) {
36
- return GenericToolkit.empty;
38
+ return OpaqueToolkit.empty;
37
39
  }
38
40
 
39
41
  const effectTools = tools.map((mcpTool) => {
40
42
  const parameters: any = {};
41
43
  for (const [key, value] of Object.entries(mcpTool.inputSchema.properties ?? {})) {
42
44
  if (mcpTool.inputSchema.required?.includes(key)) {
43
- parameters[key] = Schema.Unknown.pipe(
44
- Schema.annotations({
45
- jsonSchema: value,
46
- }),
47
- );
45
+ parameters[key] = Schema.Unknown.pipe(Schema.annotations({ jsonSchema: value }));
48
46
  } else {
49
- parameters[key] = Schema.Unknown.pipe(
50
- Schema.annotations({
51
- jsonSchema: value,
52
- }),
53
- ).pipe(Schema.optional);
47
+ parameters[key] = Schema.Unknown.pipe(Schema.annotations({ jsonSchema: value })).pipe(Schema.optional);
54
48
  }
55
49
  }
56
50
 
@@ -84,22 +78,58 @@ export const make = (options: McpToolkitOptions): Effect.Effect<GenericToolkit.G
84
78
 
85
79
  const layer = toolkit.toLayer(handlers);
86
80
 
87
- return GenericToolkit.make(toolkit, layer);
81
+ return OpaqueToolkit.make(toolkit, layer);
88
82
  }).pipe(Effect.withSpan('McpToolkit.make'));
89
83
 
84
+ /**
85
+ * Returns true when the error (or its wrapped cause) contains a 405 status code.
86
+ * `Effect.tryPromise` wraps thrown errors in `UnknownException`, so we unwrap first.
87
+ */
88
+ export const is405 = (error: unknown): boolean => {
89
+ const cause = error != null && typeof error === 'object' && 'error' in error ? (error as any).error : error;
90
+ return cause instanceof Error && cause.message.includes('405');
91
+ };
92
+
93
+ /**
94
+ * Connects to an MCP server, falling back to the alternate transport on 405 errors.
95
+ * Per the MCP spec, a 405 indicates the server uses the other transport protocol.
96
+ * Returns the connected Client (a fresh instance is created for the fallback attempt).
97
+ */
98
+ const connectWithFallback = (options: McpToolkitOptions): Effect.Effect<Client> =>
99
+ Effect.gen(function* () {
100
+ const fallbackKind = options.kind === 'sse' ? 'http' : 'sse';
101
+ const primary = yield* connectClient(options.url, options.kind, options.apiKey).pipe(Effect.either);
102
+ if (primary._tag === 'Right') {
103
+ return primary.right;
104
+ }
105
+ if (is405(primary.left)) {
106
+ return yield* connectClient(options.url, fallbackKind, options.apiKey).pipe(Effect.orDie);
107
+ }
108
+ return yield* Effect.die(primary.left);
109
+ });
110
+
111
+ const connectClient = (url: string, kind: McpToolkitOptions['kind'], apiKey?: string) =>
112
+ Effect.tryPromise(() => {
113
+ const client = new Client(CLIENT_INFO);
114
+ const transport = createTransport(url, kind, apiKey);
115
+ return client.connect(transport).then(() => client);
116
+ });
117
+
90
118
  /**
91
119
  * Creates a transport for the given MCP server URL and kind.
92
120
  */
93
121
  const createTransport = (
94
122
  url: string,
95
123
  kind: McpToolkitOptions['kind'],
124
+ apiKey?: string,
96
125
  ): SSEClientTransport | StreamableHTTPClientTransport => {
97
126
  const urlObj = new URL(url);
127
+ const requestInit: RequestInit | undefined = apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : undefined;
98
128
  switch (kind) {
99
129
  case 'sse':
100
- return new SSEClientTransport(urlObj);
130
+ return new SSEClientTransport(urlObj, { requestInit });
101
131
  case 'http':
102
- return new StreamableHTTPClientTransport(urlObj);
132
+ return new StreamableHTTPClientTransport(urlObj, { requestInit });
103
133
  default: {
104
134
  const _exhaustive: never = kind;
105
135
  return invariant(false, `Unsupported MCP transport kind: ${_exhaustive}`) as never;
@@ -125,5 +155,6 @@ const formatToolResult = (result: Awaited<ReturnType<Client['callTool']>>): stri
125
155
  .map((part) => part.text)
126
156
  .join('\n');
127
157
  }
158
+
128
159
  return String(result);
129
160
  };
@@ -0,0 +1,33 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Cause from 'effect/Cause';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import * as McpToolkit from './McpToolkit';
9
+
10
+ describe('is405', () => {
11
+ test('matches raw Error with 405 in message', ({ expect }) => {
12
+ expect(McpToolkit.is405(new Error('SSE error: Non-200 status code (405)'))).toBe(true);
13
+ });
14
+
15
+ test('matches UnknownException wrapping a 405 Error', ({ expect }) => {
16
+ const wrapped = new Cause.UnknownException(new Error('SSE error: Non-200 status code (405)'));
17
+ expect(McpToolkit.is405(wrapped)).toBe(true);
18
+ });
19
+
20
+ test('does not match unrelated errors', ({ expect }) => {
21
+ expect(McpToolkit.is405(new Error('connection refused'))).toBe(false);
22
+ });
23
+
24
+ test('does not match UnknownException wrapping a non-405 Error', ({ expect }) => {
25
+ const wrapped = new Cause.UnknownException(new Error('connection refused'));
26
+ expect(McpToolkit.is405(wrapped)).toBe(false);
27
+ });
28
+
29
+ test('does not match null or undefined', ({ expect }) => {
30
+ expect(McpToolkit.is405(null)).toBe(false);
31
+ expect(McpToolkit.is405(undefined)).toBe(false);
32
+ });
33
+ });