@fgv/ts-extras-mcp 5.1.0-34

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.
Files changed (107) hide show
  1. package/.rush/temp/5d207610a84e494acaefe7ed13447cda74ddf7da.tar.log +90 -0
  2. package/.rush/temp/chunked-rush-logs/ts-extras-mcp.build.chunks.jsonl +9 -0
  3. package/.rush/temp/operation/build/all.log +9 -0
  4. package/.rush/temp/operation/build/log-chunks.jsonl +9 -0
  5. package/.rush/temp/operation/build/state.json +3 -0
  6. package/.rush/temp/shrinkwrap-deps.json +734 -0
  7. package/CHANGELOG.json +4 -0
  8. package/README.md +133 -0
  9. package/config/api-extractor.json +38 -0
  10. package/config/jest.config.json +13 -0
  11. package/config/rig.json +6 -0
  12. package/dist/index.js +49 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/packlets/mcp/adapter.js +122 -0
  15. package/dist/packlets/mcp/adapter.js.map +1 -0
  16. package/dist/packlets/mcp/index.js +33 -0
  17. package/dist/packlets/mcp/index.js.map +1 -0
  18. package/dist/packlets/mcp/model.js +23 -0
  19. package/dist/packlets/mcp/model.js.map +1 -0
  20. package/dist/packlets/mcp/operations.js +119 -0
  21. package/dist/packlets/mcp/operations.js.map +1 -0
  22. package/dist/packlets/mcp/sdk.js +67 -0
  23. package/dist/packlets/mcp/sdk.js.map +1 -0
  24. package/dist/packlets/mcp/session.js +109 -0
  25. package/dist/packlets/mcp/session.js.map +1 -0
  26. package/dist/packlets/mcp/transports.js +96 -0
  27. package/dist/packlets/mcp/transports.js.map +1 -0
  28. package/dist/test/unit/endToEnd.test.js +220 -0
  29. package/dist/test/unit/endToEnd.test.js.map +1 -0
  30. package/dist/test/unit/index.test.js +41 -0
  31. package/dist/test/unit/index.test.js.map +1 -0
  32. package/dist/test/unit/mcp.test.js +381 -0
  33. package/dist/test/unit/mcp.test.js.map +1 -0
  34. package/dist/test/unit/sdk.test.js +68 -0
  35. package/dist/test/unit/sdk.test.js.map +1 -0
  36. package/dist/ts-extras-mcp.d.ts +290 -0
  37. package/dist/tsdoc-metadata.json +11 -0
  38. package/eslint.config.js +15 -0
  39. package/etc/ts-extras-mcp.api.md +104 -0
  40. package/lib/index.d.ts +28 -0
  41. package/lib/index.d.ts.map +1 -0
  42. package/lib/index.js +65 -0
  43. package/lib/index.js.map +1 -0
  44. package/lib/packlets/mcp/adapter.d.ts +33 -0
  45. package/lib/packlets/mcp/adapter.d.ts.map +1 -0
  46. package/lib/packlets/mcp/adapter.js +125 -0
  47. package/lib/packlets/mcp/adapter.js.map +1 -0
  48. package/lib/packlets/mcp/index.d.ts +10 -0
  49. package/lib/packlets/mcp/index.d.ts.map +1 -0
  50. package/lib/packlets/mcp/index.js +57 -0
  51. package/lib/packlets/mcp/index.js.map +1 -0
  52. package/lib/packlets/mcp/model.d.ts +155 -0
  53. package/lib/packlets/mcp/model.d.ts.map +1 -0
  54. package/lib/packlets/mcp/model.js +24 -0
  55. package/lib/packlets/mcp/model.js.map +1 -0
  56. package/lib/packlets/mcp/operations.d.ts +36 -0
  57. package/lib/packlets/mcp/operations.d.ts.map +1 -0
  58. package/lib/packlets/mcp/operations.js +123 -0
  59. package/lib/packlets/mcp/operations.js.map +1 -0
  60. package/lib/packlets/mcp/sdk.d.ts +91 -0
  61. package/lib/packlets/mcp/sdk.d.ts.map +1 -0
  62. package/lib/packlets/mcp/sdk.js +72 -0
  63. package/lib/packlets/mcp/sdk.js.map +1 -0
  64. package/lib/packlets/mcp/session.d.ts +43 -0
  65. package/lib/packlets/mcp/session.d.ts.map +1 -0
  66. package/lib/packlets/mcp/session.js +115 -0
  67. package/lib/packlets/mcp/session.js.map +1 -0
  68. package/lib/packlets/mcp/transports.d.ts +49 -0
  69. package/lib/packlets/mcp/transports.d.ts.map +1 -0
  70. package/lib/packlets/mcp/transports.js +102 -0
  71. package/lib/packlets/mcp/transports.js.map +1 -0
  72. package/lib/test/unit/endToEnd.test.d.ts +13 -0
  73. package/lib/test/unit/endToEnd.test.d.ts.map +1 -0
  74. package/lib/test/unit/endToEnd.test.js +222 -0
  75. package/lib/test/unit/endToEnd.test.js.map +1 -0
  76. package/lib/test/unit/index.test.d.ts +2 -0
  77. package/lib/test/unit/index.test.d.ts.map +1 -0
  78. package/lib/test/unit/index.test.js +76 -0
  79. package/lib/test/unit/index.test.js.map +1 -0
  80. package/lib/test/unit/mcp.test.d.ts +2 -0
  81. package/lib/test/unit/mcp.test.d.ts.map +1 -0
  82. package/lib/test/unit/mcp.test.js +416 -0
  83. package/lib/test/unit/mcp.test.js.map +1 -0
  84. package/lib/test/unit/sdk.test.d.ts +2 -0
  85. package/lib/test/unit/sdk.test.d.ts.map +1 -0
  86. package/lib/test/unit/sdk.test.js +70 -0
  87. package/lib/test/unit/sdk.test.js.map +1 -0
  88. package/package.json +86 -0
  89. package/rush-logs/ts-extras-mcp.build.cache.log +3 -0
  90. package/rush-logs/ts-extras-mcp.build.log +9 -0
  91. package/src/index.ts +50 -0
  92. package/src/packlets/mcp/adapter.ts +152 -0
  93. package/src/packlets/mcp/index.ts +34 -0
  94. package/src/packlets/mcp/model.ts +204 -0
  95. package/src/packlets/mcp/operations.ts +138 -0
  96. package/src/packlets/mcp/sdk.ts +148 -0
  97. package/src/packlets/mcp/session.ts +137 -0
  98. package/src/packlets/mcp/transports.ts +111 -0
  99. package/src/test/unit/endToEnd.test.ts +254 -0
  100. package/src/test/unit/index.test.ts +43 -0
  101. package/src/test/unit/mcp.test.ts +473 -0
  102. package/src/test/unit/sdk.test.ts +73 -0
  103. package/temp/build/lint/_eslint-5eVG3S6w.json +54 -0
  104. package/temp/build/typescript/ts_8nwakTlr.json +1 -0
  105. package/temp/ts-extras-mcp.api.json +1662 -0
  106. package/temp/ts-extras-mcp.api.md +104 -0
  107. package/tsconfig.json +12 -0
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+
23
+ import '@fgv/ts-utils-jest';
24
+ import * as mcp from '../../index';
25
+
26
+ describe('@fgv/ts-extras-mcp package barrel', () => {
27
+ test('re-exports the public boundary primitives', () => {
28
+ expect(typeof mcp.createStdioTransport).toBe('function');
29
+ expect(typeof mcp.createHttpTransport).toBe('function');
30
+ expect(typeof mcp.connectMcpSession).toBe('function');
31
+ expect(typeof mcp.closeMcpSession).toBe('function');
32
+ expect(typeof mcp.listMcpTools).toBe('function');
33
+ expect(typeof mcp.callMcpTool).toBe('function');
34
+ expect(typeof mcp.adaptMcpTools).toBe('function');
35
+ });
36
+
37
+ test('the re-exported transport factories are the real (behaving) implementations', () => {
38
+ // Behavioral signal beyond `typeof`: a renamed-but-still-a-function export would not behave
39
+ // this way. (The synchronous transport factories validate without touching the SDK/network.)
40
+ expect(mcp.createStdioTransport({ command: '' })).toFailWith(/command must be a non-empty string/);
41
+ expect(mcp.createHttpTransport({ url: 'not a url' })).toFailWith(/invalid url/);
42
+ });
43
+ });
@@ -0,0 +1,473 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+
23
+ // Mock the SDK-isolation seam so no live MCP server / subprocess is required. The factories
24
+ // return controllable fakes; `makeClient` is configured per-test. The jest.mock call must
25
+ // precede the regular imports (hoist-jest-mock).
26
+ jest.mock('../../packlets/mcp/sdk', () => ({
27
+ makeClient: jest.fn(),
28
+ makeStdioTransport: jest.fn(() => ({ sentinel: 'stdio' })),
29
+ makeHttpTransport: jest.fn(() => ({ sentinel: 'http' }))
30
+ }));
31
+
32
+ import '@fgv/ts-utils-jest';
33
+ import { Logging, type Result } from '@fgv/ts-utils';
34
+ import { type JsonObject, type JsonValue } from '@fgv/ts-json-base';
35
+
36
+ // eslint-disable-next-line @rushstack/packlets/mechanics
37
+ import * as sdk from '../../packlets/mcp/sdk';
38
+ import {
39
+ type IAdaptMcpToolsResult,
40
+ type IMcpSession,
41
+ type IMcpToolDescriptor,
42
+ type IMcpTransport,
43
+ adaptMcpTools,
44
+ callMcpTool,
45
+ closeMcpSession,
46
+ connectMcpSession,
47
+ createHttpTransport,
48
+ createStdioTransport,
49
+ listMcpTools
50
+ } from '../../packlets/mcp';
51
+
52
+ const mockSdk = sdk as jest.Mocked<typeof sdk>;
53
+
54
+ interface IFakeClient {
55
+ connect: jest.Mock;
56
+ getServerVersion: jest.Mock;
57
+ listTools: jest.Mock;
58
+ callTool: jest.Mock;
59
+ close: jest.Mock;
60
+ }
61
+
62
+ function makeFakeClient(overrides: Partial<IFakeClient> = {}): IFakeClient {
63
+ return {
64
+ connect: jest.fn(async () => undefined),
65
+ getServerVersion: jest.fn(() => ({ name: 'fake-server', version: '1.2.3' })),
66
+ listTools: jest.fn(async () => ({ tools: [] })),
67
+ callTool: jest.fn(async () => ({ content: [] })),
68
+ close: jest.fn(async () => undefined),
69
+ ...overrides
70
+ };
71
+ }
72
+
73
+ /** Connect a session backed by the given fake client (and a stdio transport). */
74
+ async function connectWith(fake: IFakeClient): Promise<IMcpSession> {
75
+ mockSdk.makeClient.mockReturnValueOnce(fake as unknown as sdk.ISdkClient);
76
+ const transport = createStdioTransport({ command: 'node' }).orThrow();
77
+ return (await connectMcpSession({ transport })).orThrow();
78
+ }
79
+
80
+ /** A foreign (non-package) handle, to exercise the loud narrowing-failure paths. */
81
+ const FOREIGN_TRANSPORT = { transportKind: 'stdio' } as unknown as IMcpTransport;
82
+ const FOREIGN_SESSION = {
83
+ clientName: 'x',
84
+ clientVersion: 'y',
85
+ serverInfo: undefined
86
+ } as unknown as IMcpSession;
87
+
88
+ beforeEach(() => {
89
+ jest.clearAllMocks();
90
+ mockSdk.makeStdioTransport.mockReturnValue({ sentinel: 'stdio' } as unknown as sdk.ISdkTransport);
91
+ mockSdk.makeHttpTransport.mockReturnValue({ sentinel: 'http' } as unknown as sdk.ISdkTransport);
92
+ });
93
+
94
+ // ===========================================================================
95
+ // Transports
96
+ // ===========================================================================
97
+
98
+ describe('createStdioTransport', () => {
99
+ test('succeeds for a valid command', () => {
100
+ expect(createStdioTransport({ command: 'node', args: ['x'] })).toSucceedAndSatisfy((t: IMcpTransport) => {
101
+ expect(t.transportKind).toBe('stdio');
102
+ });
103
+ expect(mockSdk.makeStdioTransport).toHaveBeenCalledWith(
104
+ expect.objectContaining({ command: 'node', args: ['x'] })
105
+ );
106
+ });
107
+
108
+ test('fails for an empty/whitespace command', () => {
109
+ expect(createStdioTransport({ command: '' })).toFailWith(/command must be a non-empty string/);
110
+ expect(createStdioTransport({ command: ' ' })).toFailWith(/command must be a non-empty string/);
111
+ });
112
+
113
+ test('fails (Result) when the SDK constructor throws', () => {
114
+ mockSdk.makeStdioTransport.mockImplementationOnce(() => {
115
+ throw new Error('spawn boom');
116
+ });
117
+ expect(createStdioTransport({ command: 'node' })).toFailWith(/createStdioTransport:.*spawn boom/);
118
+ });
119
+ });
120
+
121
+ describe('createHttpTransport', () => {
122
+ test('succeeds for http and https urls and forwards headers', () => {
123
+ expect(createHttpTransport({ url: 'http://localhost:9000/mcp' })).toSucceedAndSatisfy(
124
+ (t: IMcpTransport) => {
125
+ expect(t.transportKind).toBe('http');
126
+ }
127
+ );
128
+ expect(
129
+ createHttpTransport({ url: 'https://example.com/mcp', headers: { authorization: 'Bearer t' } })
130
+ ).toSucceed();
131
+ expect(mockSdk.makeHttpTransport).toHaveBeenCalledWith(expect.any(URL), { authorization: 'Bearer t' });
132
+ });
133
+
134
+ test('fails for an unparseable url', () => {
135
+ expect(createHttpTransport({ url: 'not a url' })).toFailWith(/invalid url 'not a url'/);
136
+ });
137
+
138
+ test('fails for a non-http(s) protocol', () => {
139
+ expect(createHttpTransport({ url: 'ftp://example.com' })).toFailWith(/must use http or https/);
140
+ });
141
+
142
+ test('fails (Result) when the SDK constructor throws', () => {
143
+ mockSdk.makeHttpTransport.mockImplementationOnce(() => {
144
+ throw new Error('http boom');
145
+ });
146
+ expect(createHttpTransport({ url: 'http://localhost' })).toFailWith(/createHttpTransport:.*http boom/);
147
+ });
148
+ });
149
+
150
+ // ===========================================================================
151
+ // Session lifecycle
152
+ // ===========================================================================
153
+
154
+ describe('connectMcpSession', () => {
155
+ test('fails loudly for a foreign transport handle', async () => {
156
+ expect(await connectMcpSession({ transport: FOREIGN_TRANSPORT })).toFailWith(/invalid MCP transport/);
157
+ });
158
+
159
+ test('connects and reports server identity, logging when a logger is supplied', async () => {
160
+ const logger = new Logging.InMemoryLogger('all');
161
+ const fake = makeFakeClient();
162
+ mockSdk.makeClient.mockReturnValueOnce(fake as unknown as sdk.ISdkClient);
163
+ const transport = createStdioTransport({ command: 'node' }).orThrow();
164
+
165
+ expect(
166
+ await connectMcpSession({ transport, clientName: 'me', clientVersion: '0.0.1', logger })
167
+ ).toSucceedAndSatisfy((session: IMcpSession) => {
168
+ expect(session.clientName).toBe('me');
169
+ expect(session.clientVersion).toBe('0.0.1');
170
+ expect(session.serverInfo).toEqual({ name: 'fake-server', version: '1.2.3' });
171
+ });
172
+ expect(mockSdk.makeClient).toHaveBeenCalledWith('me', '0.0.1');
173
+ expect(fake.connect).toHaveBeenCalledTimes(1);
174
+ expect(logger.logged.join('\n')).toMatch(/connected to server fake-server@1.2.3/);
175
+ });
176
+
177
+ test('connects with default identity and no server info, no logger', async () => {
178
+ const fake = makeFakeClient({ getServerVersion: jest.fn(() => undefined) });
179
+ mockSdk.makeClient.mockReturnValueOnce(fake as unknown as sdk.ISdkClient);
180
+ const transport = createStdioTransport({ command: 'node' }).orThrow();
181
+
182
+ expect(await connectMcpSession({ transport })).toSucceedAndSatisfy((session: IMcpSession) => {
183
+ expect(session.clientName).toBe('@fgv/ts-extras-mcp');
184
+ expect(session.serverInfo).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ test('logs the no-identity case when a logger is supplied', async () => {
189
+ const logger = new Logging.InMemoryLogger('all');
190
+ const fake = makeFakeClient({ getServerVersion: jest.fn(() => undefined) });
191
+ mockSdk.makeClient.mockReturnValueOnce(fake as unknown as sdk.ISdkClient);
192
+ const transport = createStdioTransport({ command: 'node' }).orThrow();
193
+
194
+ expect(await connectMcpSession({ transport, logger })).toSucceed();
195
+ expect(logger.logged.join('\n')).toMatch(/connected \(server did not report identity\)/);
196
+ });
197
+
198
+ test('fails when the connect handshake throws', async () => {
199
+ const fake = makeFakeClient({
200
+ connect: jest.fn(async () => {
201
+ throw new Error('handshake refused');
202
+ })
203
+ });
204
+ mockSdk.makeClient.mockReturnValueOnce(fake as unknown as sdk.ISdkClient);
205
+ const transport = createStdioTransport({ command: 'node' }).orThrow();
206
+
207
+ expect(await connectMcpSession({ transport })).toFailWith(/connectMcpSession:.*handshake refused/);
208
+ });
209
+ });
210
+
211
+ describe('closeMcpSession', () => {
212
+ test('fails loudly for a foreign session handle', async () => {
213
+ expect(await closeMcpSession(FOREIGN_SESSION)).toFailWith(/invalid MCP session/);
214
+ });
215
+
216
+ test('closes a connected session', async () => {
217
+ const fake = makeFakeClient();
218
+ const session = await connectWith(fake);
219
+ expect(await closeMcpSession(session)).toSucceedWith(true);
220
+ expect(fake.close).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ test('fails when close throws', async () => {
224
+ const fake = makeFakeClient({
225
+ close: jest.fn(async () => {
226
+ throw new Error('already closed');
227
+ })
228
+ });
229
+ const session = await connectWith(fake);
230
+ expect(await closeMcpSession(session)).toFailWith(/closeMcpSession:.*already closed/);
231
+ });
232
+ });
233
+
234
+ // ===========================================================================
235
+ // Tool discovery + invocation
236
+ // ===========================================================================
237
+
238
+ describe('listMcpTools', () => {
239
+ test('fails loudly for a foreign session handle', async () => {
240
+ expect(await listMcpTools(FOREIGN_SESSION)).toFailWith(/invalid MCP session/);
241
+ });
242
+
243
+ test('returns a single-page catalog', async () => {
244
+ const fake = makeFakeClient({
245
+ listTools: jest.fn(async () => ({
246
+ tools: [{ name: 'a', description: 'tool a', inputSchema: { type: 'object' } }]
247
+ }))
248
+ });
249
+ const session = await connectWith(fake);
250
+ expect(await listMcpTools(session)).toSucceedAndSatisfy((tools: ReadonlyArray<IMcpToolDescriptor>) => {
251
+ expect(tools).toHaveLength(1);
252
+ expect(tools[0]).toEqual({ name: 'a', description: 'tool a', inputSchema: { type: 'object' } });
253
+ });
254
+ });
255
+
256
+ test('follows nextCursor across pages', async () => {
257
+ const listTools = jest
258
+ .fn()
259
+ .mockResolvedValueOnce({ tools: [{ name: 'a', inputSchema: { type: 'object' } }], nextCursor: 'p2' })
260
+ .mockResolvedValueOnce({ tools: [{ name: 'b', inputSchema: { type: 'object' } }] });
261
+ const fake = makeFakeClient({ listTools });
262
+ const session = await connectWith(fake);
263
+
264
+ expect(await listMcpTools(session)).toSucceedAndSatisfy((tools: ReadonlyArray<IMcpToolDescriptor>) => {
265
+ expect(tools.map((t) => t.name)).toEqual(['a', 'b']);
266
+ });
267
+ expect(listTools).toHaveBeenNthCalledWith(1, undefined);
268
+ expect(listTools).toHaveBeenNthCalledWith(2, { cursor: 'p2' });
269
+ });
270
+
271
+ test('coerces a missing/invalid inputSchema to null and a missing description to undefined', async () => {
272
+ const fake = makeFakeClient({
273
+ listTools: jest.fn(async () => ({ tools: [{ name: 'noschema' }] }))
274
+ });
275
+ const session = await connectWith(fake);
276
+ expect(await listMcpTools(session)).toSucceedAndSatisfy((tools: ReadonlyArray<IMcpToolDescriptor>) => {
277
+ expect(tools[0]).toEqual({ name: 'noschema', description: undefined, inputSchema: null });
278
+ });
279
+ });
280
+
281
+ test('fails when listTools throws', async () => {
282
+ const fake = makeFakeClient({
283
+ listTools: jest.fn(async () => {
284
+ throw new Error('protocol error');
285
+ })
286
+ });
287
+ const session = await connectWith(fake);
288
+ expect(await listMcpTools(session)).toFailWith(/listMcpTools:.*protocol error/);
289
+ });
290
+ });
291
+
292
+ describe('callMcpTool', () => {
293
+ const args: JsonObject = { q: 'hi' };
294
+
295
+ test('fails loudly for a foreign session handle', async () => {
296
+ expect(await callMcpTool(FOREIGN_SESSION, 'a', args)).toFailWith(/invalid MCP session/);
297
+ });
298
+
299
+ test('concatenates text blocks', async () => {
300
+ const fake = makeFakeClient({
301
+ callTool: jest.fn(async () => ({
302
+ content: [
303
+ { type: 'text', text: 'line one' },
304
+ { type: 'text', text: 'line two' }
305
+ ]
306
+ }))
307
+ });
308
+ const session = await connectWith(fake);
309
+ expect(await callMcpTool(session, 'a', args)).toSucceedWith({
310
+ content: 'line one\nline two'
311
+ });
312
+ expect(fake.callTool).toHaveBeenCalledWith({ name: 'a', arguments: args });
313
+ });
314
+
315
+ test('summarizes non-text blocks', async () => {
316
+ const fake = makeFakeClient({
317
+ callTool: jest.fn(async () => ({
318
+ content: [
319
+ { type: 'text', text: 'caption' },
320
+ { type: 'image', data: 'base64==' }
321
+ ]
322
+ }))
323
+ });
324
+ const session = await connectWith(fake);
325
+ expect(await callMcpTool(session, 'a', args)).toSucceedWith({
326
+ content: 'caption\n[image block]'
327
+ });
328
+ });
329
+
330
+ test('projects an empty/absent content list to an empty string', async () => {
331
+ const fake = makeFakeClient({ callTool: jest.fn(async () => ({})) });
332
+ const session = await connectWith(fake);
333
+ expect(await callMcpTool(session, 'a', args)).toSucceedWith({ content: '' });
334
+ });
335
+
336
+ test('maps isError:true to a Failure carrying the projected content', async () => {
337
+ const fake = makeFakeClient({
338
+ callTool: jest.fn(async () => ({ content: [{ type: 'text', text: 'bad input' }], isError: true }))
339
+ });
340
+ const session = await connectWith(fake);
341
+ expect(await callMcpTool(session, 'a', args)).toFailWith(/^bad input$/);
342
+ });
343
+
344
+ test('maps isError:true with no content to a generic error', async () => {
345
+ const fake = makeFakeClient({ callTool: jest.fn(async () => ({ isError: true })) });
346
+ const session = await connectWith(fake);
347
+ expect(await callMcpTool(session, 'broken', args)).toFailWith(/tool 'broken' reported an error/);
348
+ });
349
+
350
+ test('fails when callTool throws', async () => {
351
+ const fake = makeFakeClient({
352
+ callTool: jest.fn(async () => {
353
+ throw new Error('timeout');
354
+ })
355
+ });
356
+ const session = await connectWith(fake);
357
+ expect(await callMcpTool(session, 'a', args)).toFailWith(/callMcpTool 'a':.*timeout/);
358
+ });
359
+ });
360
+
361
+ // ===========================================================================
362
+ // adaptMcpTools — the headline (Constraint 1)
363
+ // ===========================================================================
364
+
365
+ describe('adaptMcpTools', () => {
366
+ const goodSchema: JsonValue = {
367
+ type: 'object',
368
+ properties: { q: { type: 'string' } },
369
+ required: ['q']
370
+ };
371
+ // `pattern` is outside the JsonSchema.fromJson supported subset → fromJson fails with a pointer.
372
+ const forbiddenSchema: JsonValue = {
373
+ type: 'object',
374
+ properties: { name: { type: 'string', pattern: '^a' } }
375
+ };
376
+
377
+ function mixedCatalogClient(): IFakeClient {
378
+ return makeFakeClient({
379
+ listTools: jest.fn(async () => ({
380
+ tools: [
381
+ { name: 'good', description: 'a good tool', inputSchema: goodSchema },
382
+ { name: 'forbidden', inputSchema: forbiddenSchema },
383
+ { name: 'nonobject', description: 'bad', inputSchema: 'not-a-schema' }
384
+ ]
385
+ }))
386
+ });
387
+ }
388
+
389
+ test('propagates a tool-discovery failure', async () => {
390
+ const fake = makeFakeClient({
391
+ listTools: jest.fn(async () => {
392
+ throw new Error('discovery down');
393
+ })
394
+ });
395
+ const session = await connectWith(fake);
396
+ expect(await adaptMcpTools(session)).toFailWith(/adaptMcpTools:.*discovery down/);
397
+ });
398
+
399
+ test('adapts good tools and surfaces skipped tools structurally + NOISY warn', async () => {
400
+ const logger = new Logging.InMemoryLogger('all');
401
+ const session = await connectWith(mixedCatalogClient());
402
+
403
+ expect(await adaptMcpTools(session, { logger })).toSucceedAndSatisfy((result: IAdaptMcpToolsResult) => {
404
+ expect(result.tools).toHaveLength(1);
405
+ expect(result.tools[0].config.name).toBe('good');
406
+ expect(result.tools[0].config.description).toBe('a good tool');
407
+
408
+ expect(result.skipped).toHaveLength(2);
409
+ const byName = new Map(result.skipped.map((s) => [s.name, s]));
410
+ expect(byName.get('forbidden')?.reason).toMatch(/pattern/);
411
+ expect(byName.get('forbidden')?.schema).toEqual(forbiddenSchema);
412
+ expect(byName.get('nonobject')?.reason).toMatch(/not a JSON object/);
413
+ expect(byName.get('nonobject')?.schema).toBe('not-a-schema');
414
+ });
415
+
416
+ // NOISY warn: tool name + pointer reason + the raw failing schema verbatim.
417
+ const warned = logger.logged.join('\n');
418
+ expect(warned).toMatch(/skipping tool 'forbidden'/);
419
+ expect(warned).toMatch(/pattern/);
420
+ expect(warned).toContain(JSON.stringify(forbiddenSchema));
421
+ expect(warned).toMatch(/skipping tool 'nonobject'/);
422
+ });
423
+
424
+ test('surfaces skips structurally even without a logger', async () => {
425
+ const session = await connectWith(mixedCatalogClient());
426
+ expect(await adaptMcpTools(session)).toSucceedAndSatisfy((result: IAdaptMcpToolsResult) => {
427
+ expect(result.tools).toHaveLength(1);
428
+ expect(result.skipped).toHaveLength(2);
429
+ });
430
+ });
431
+
432
+ test('falls back to the tool name when no description is provided', async () => {
433
+ const fake = makeFakeClient({
434
+ listTools: jest.fn(async () => ({ tools: [{ name: 'nodesc', inputSchema: goodSchema }] }))
435
+ });
436
+ const session = await connectWith(fake);
437
+ expect(await adaptMcpTools(session)).toSucceedAndSatisfy((result: IAdaptMcpToolsResult) => {
438
+ expect(result.tools[0].config.description).toBe('nodesc');
439
+ });
440
+ });
441
+
442
+ describe('adapted tool execute', () => {
443
+ async function adaptGood(callTool: jest.Mock): Promise<(args: unknown) => Promise<Result<unknown>>> {
444
+ const fake = makeFakeClient({
445
+ listTools: jest.fn(async () => ({ tools: [{ name: 'good', inputSchema: goodSchema }] })),
446
+ callTool
447
+ });
448
+ const session = await connectWith(fake);
449
+ const result = (await adaptMcpTools(session)).orThrow();
450
+ return result.tools[0].execute;
451
+ }
452
+
453
+ test('forwards validated args to callMcpTool and returns the projected content', async () => {
454
+ const callTool = jest.fn(async () => ({ content: [{ type: 'text', text: 'tool output' }] }));
455
+ const execute = await adaptGood(callTool);
456
+ expect(await execute({ q: 'hello' })).toSucceedWith('tool output');
457
+ expect(callTool).toHaveBeenCalledWith({ name: 'good', arguments: { q: 'hello' } });
458
+ });
459
+
460
+ test('fails when the model supplies non-object arguments', async () => {
461
+ const callTool = jest.fn();
462
+ const execute = await adaptGood(callTool);
463
+ expect(await execute('not-an-object')).toFailWith(/tool 'good': arguments must be a JSON object/);
464
+ expect(callTool).not.toHaveBeenCalled();
465
+ });
466
+
467
+ test('propagates an MCP tool error as a Failure', async () => {
468
+ const callTool = jest.fn(async () => ({ content: [{ type: 'text', text: 'denied' }], isError: true }));
469
+ const execute = await adaptGood(callTool);
470
+ expect(await execute({ q: 'hello' })).toFailWith(/denied/);
471
+ });
472
+ });
473
+ });
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+
23
+ // Exercises the real SDK-isolation seam (the one file that imports @modelcontextprotocol/sdk).
24
+ // Constructing the SDK objects is side-effect-free — no process is spawned and no network call is
25
+ // made until a transport is connected, so these factory calls are safe in a unit test.
26
+ // eslint-disable-next-line @rushstack/packlets/mechanics
27
+ import { makeClient, makeHttpTransport, makeStdioTransport } from '../../packlets/mcp/sdk';
28
+
29
+ describe('sdk isolation seam', () => {
30
+ describe('makeClient', () => {
31
+ test('constructs a client exposing the projected surface', () => {
32
+ const client = makeClient('test-client', '9.9.9');
33
+ expect(typeof client.connect).toBe('function');
34
+ expect(typeof client.listTools).toBe('function');
35
+ expect(typeof client.callTool).toBe('function');
36
+ expect(typeof client.close).toBe('function');
37
+ expect(typeof client.getServerVersion).toBe('function');
38
+ // Before connecting, the server identity is unknown.
39
+ expect(client.getServerVersion()).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe('makeStdioTransport', () => {
44
+ test('constructs a transport with full params (args/env/cwd)', () => {
45
+ const transport = makeStdioTransport({
46
+ command: 'node',
47
+ args: ['--version'],
48
+ env: { FOO: 'bar' },
49
+ cwd: '/tmp'
50
+ });
51
+ expect(transport).toBeDefined();
52
+ });
53
+
54
+ test('constructs a transport with only a command (args omitted)', () => {
55
+ const transport = makeStdioTransport({ command: 'node' });
56
+ expect(transport).toBeDefined();
57
+ });
58
+ });
59
+
60
+ describe('makeHttpTransport', () => {
61
+ test('constructs a transport with headers', () => {
62
+ const transport = makeHttpTransport(new URL('http://localhost:9000/mcp'), {
63
+ authorization: 'Bearer x'
64
+ });
65
+ expect(transport).toBeDefined();
66
+ });
67
+
68
+ test('constructs a transport without headers', () => {
69
+ const transport = makeHttpTransport(new URL('https://example.com/mcp'));
70
+ expect(transport).toBeDefined();
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,54 @@
1
+ {
2
+ "cacheVersion": "9.39.4_v22.22.1",
3
+ "fileVersions": [
4
+ [
5
+ "packlets/mcp/model.ts",
6
+ "b6be99c7f76f1434478a5361a343e356ffb0007be2fd764f116a5550563866ca_FwUaQzIBc5UaUB1mvZjk6wqndSg="
7
+ ],
8
+ [
9
+ "packlets/mcp/sdk.ts",
10
+ "bff9c56c6730036468fd0291b9666d71896e1b6684768947ca75f686239a5f08_FwUaQzIBc5UaUB1mvZjk6wqndSg="
11
+ ],
12
+ [
13
+ "packlets/mcp/transports.ts",
14
+ "4df8a3db621238e35de5887ea840787477f80f35d6adb83efd4e5ad84d6e7c45_FwUaQzIBc5UaUB1mvZjk6wqndSg="
15
+ ],
16
+ [
17
+ "packlets/mcp/session.ts",
18
+ "67b2c8be3cb80f09c36fa9f2debafe019da3c02294b7e2095fb8ed656ca7d3ee_FwUaQzIBc5UaUB1mvZjk6wqndSg="
19
+ ],
20
+ [
21
+ "packlets/mcp/operations.ts",
22
+ "e4febfd8217dcef9851039d2f3ef22adaa9d8a06ee4dd7e3b22f717d0e90b543_FwUaQzIBc5UaUB1mvZjk6wqndSg="
23
+ ],
24
+ [
25
+ "packlets/mcp/adapter.ts",
26
+ "66fbecd9acc4d027ca68232f9b99c20c1f5ba83e48c88fc0248ee55dc5e9cbfa_FwUaQzIBc5UaUB1mvZjk6wqndSg="
27
+ ],
28
+ [
29
+ "packlets/mcp/index.ts",
30
+ "4bf1930d7ea44cb1f1cae555faaa26fd37bff7683af1f2c4ff5efd92c68521de_FwUaQzIBc5UaUB1mvZjk6wqndSg="
31
+ ],
32
+ [
33
+ "index.ts",
34
+ "00328713c318c9816d220a4f14eb9174f45e1800a7a323a8a333cf76757fae41_FwUaQzIBc5UaUB1mvZjk6wqndSg="
35
+ ],
36
+ [
37
+ "test/unit/endToEnd.test.ts",
38
+ "256a89ff1988594da267eeca1640488e7f615b471c45e581ee0fb6a5c2dfb9ed_xfBUGNXOR0kXPM9e4JvcwWHnvWs="
39
+ ],
40
+ [
41
+ "test/unit/index.test.ts",
42
+ "d20b79c8b6bf8d90e3bb34f5fe80cb5e1744a2562d29e6582cec10305bf66764_xfBUGNXOR0kXPM9e4JvcwWHnvWs="
43
+ ],
44
+ [
45
+ "test/unit/mcp.test.ts",
46
+ "da2a2ede0f2897396ac07571107a1a8b1fc9bbb11707739ea8579f4bddca8d20_xfBUGNXOR0kXPM9e4JvcwWHnvWs="
47
+ ],
48
+ [
49
+ "test/unit/sdk.test.ts",
50
+ "d9225df4f67cccfda9a97f639e7199996d0568358231ede128ec718f157bfdb7_xfBUGNXOR0kXPM9e4JvcwWHnvWs="
51
+ ]
52
+ ],
53
+ "filesHash": "9Dj6OR7cDSLw5PfghgpIJg"
54
+ }