@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/dist/lib/neutral/index.mjs +133 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/types/src/McpToolkit.d.ts +14 -0
- package/dist/types/src/McpToolkit.d.ts.map +1 -0
- package/dist/types/src/McpToolkit.test.d.ts +2 -0
- package/dist/types/src/McpToolkit.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/is405.test.d.ts +2 -0
- package/dist/types/src/is405.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +6 -6
- package/src/McpToolkit.test.ts +30 -5
- package/src/McpToolkit.ts +53 -22
- package/src/is405.test.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/mcp-client",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
29
|
-
"@dxos/ai": "0.8.
|
|
30
|
-
"@dxos/effect": "0.8.
|
|
31
|
-
"@dxos/invariant": "0.8.
|
|
32
|
-
"@dxos/log": "0.8.
|
|
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"
|
package/src/McpToolkit.test.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
-
{
|
|
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<
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
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
|
|
28
|
+
* @returns An OpaqueToolkit containing all tools from the MCP server.
|
|
26
29
|
*/
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|