@mcp-i/core 0.1.0 → 1.1.0-canary.1

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,311 @@
1
+ /**
2
+ * withMCPI() Integration Tests
3
+ *
4
+ * Tests the dream API: `withMCPI(server, { crypto })` auto-registers
5
+ * the handshake tool and auto-attaches proofs to all tool responses.
6
+ */
7
+
8
+ import { describe, it, expect, afterEach } from 'vitest';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
12
+ import { z } from 'zod';
13
+ import { withMCPI, generateIdentity } from '../../middleware/with-mcpi-server.js';
14
+ import { NodeCryptoProvider } from '../utils/node-crypto-provider.js';
15
+
16
+ // ── Helpers ──────────────────────────────────────────────────────
17
+
18
+ async function createTestPair(options?: {
19
+ proofAllTools?: boolean;
20
+ excludeTools?: string[];
21
+ autoSession?: boolean;
22
+ registerToolsBeforeWithMCPI?: boolean;
23
+ }) {
24
+ const crypto = new NodeCryptoProvider();
25
+ const server = new McpServer(
26
+ { name: 'withMCPI-test', version: '1.0.0' },
27
+ { instructions: 'Test server for withMCPI integration' },
28
+ );
29
+
30
+ // Register tools BEFORE withMCPI to test pre-existing tool interception
31
+ if (options?.registerToolsBeforeWithMCPI) {
32
+ server.registerTool(
33
+ 'greet',
34
+ {
35
+ description: 'Greet someone',
36
+ inputSchema: { name: z.string() },
37
+ },
38
+ async ({ name }) => ({
39
+ content: [{ type: 'text', text: `Hello, ${name}!` }],
40
+ }),
41
+ );
42
+ }
43
+
44
+ const mcpi = await withMCPI(server, {
45
+ crypto,
46
+ autoSession: options?.autoSession ?? true,
47
+ proofAllTools: options?.proofAllTools,
48
+ excludeTools: options?.excludeTools,
49
+ });
50
+
51
+ // Register tools AFTER withMCPI to test late registration
52
+ if (!options?.registerToolsBeforeWithMCPI) {
53
+ server.registerTool(
54
+ 'greet',
55
+ {
56
+ description: 'Greet someone',
57
+ inputSchema: { name: z.string() },
58
+ },
59
+ async ({ name }) => ({
60
+ content: [{ type: 'text', text: `Hello, ${name}!` }],
61
+ }),
62
+ );
63
+ }
64
+
65
+ server.registerTool(
66
+ 'add',
67
+ {
68
+ description: 'Add two numbers',
69
+ inputSchema: { a: z.number(), b: z.number() },
70
+ },
71
+ async ({ a, b }) => ({
72
+ content: [{ type: 'text', text: `${a + b}` }],
73
+ }),
74
+ );
75
+
76
+ const client = new Client(
77
+ { name: 'withMCPI-test-client', version: '1.0.0' },
78
+ );
79
+
80
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
81
+ await server.connect(serverTransport);
82
+ await client.connect(clientTransport);
83
+
84
+ return { client, server, mcpi, crypto };
85
+ }
86
+
87
+ // ── Tests ──────────────────────────────────────────────────────
88
+
89
+ describe('withMCPI()', () => {
90
+ const pairs: Array<{ client: Client; server: McpServer }> = [];
91
+
92
+ afterEach(async () => {
93
+ for (const pair of pairs) {
94
+ await pair.client.close();
95
+ await pair.server.close();
96
+ }
97
+ pairs.length = 0;
98
+ });
99
+
100
+ async function create(options?: Parameters<typeof createTestPair>[0]) {
101
+ const pair = await createTestPair(options);
102
+ pairs.push(pair);
103
+ return pair;
104
+ }
105
+
106
+ it('auto-registers _mcpi_handshake tool', async () => {
107
+ const { client } = await create();
108
+
109
+ const result = await client.listTools();
110
+ const toolNames = result.tools.map((t) => t.name);
111
+
112
+ expect(toolNames).toContain('_mcpi_handshake');
113
+ });
114
+
115
+ it('auto-proofs all registered tools', async () => {
116
+ const { client } = await create();
117
+
118
+ const result = await client.callTool({
119
+ name: 'greet',
120
+ arguments: { name: 'World' },
121
+ });
122
+
123
+ const first = result.content[0] as { type: string; text: string };
124
+ expect(first.text).toBe('Hello, World!');
125
+
126
+ // Proof should be present via auto-session + auto-proof
127
+ expect(result._meta).toBeDefined();
128
+ const proof = (result._meta as Record<string, unknown>).proof as {
129
+ jws: string;
130
+ meta: Record<string, unknown>;
131
+ };
132
+ expect(proof).toBeDefined();
133
+ expect(proof.jws).toBeDefined();
134
+ expect(proof.meta.did).toMatch(/^did:key:/);
135
+ expect(proof.meta.sessionId).toMatch(/^mcpi_/);
136
+ });
137
+
138
+ it('tools registered before withMCPI also get proofs', async () => {
139
+ const { client } = await create({ registerToolsBeforeWithMCPI: true });
140
+
141
+ const result = await client.callTool({
142
+ name: 'greet',
143
+ arguments: { name: 'Early Bird' },
144
+ });
145
+
146
+ const first = result.content[0] as { type: string; text: string };
147
+ expect(first.text).toBe('Hello, Early Bird!');
148
+
149
+ expect(result._meta).toBeDefined();
150
+ const proof = (result._meta as Record<string, unknown>).proof as {
151
+ jws: string;
152
+ };
153
+ expect(proof).toBeDefined();
154
+ expect(proof.jws).toBeDefined();
155
+ });
156
+
157
+ it('excludeTools skips proof for specified tools', async () => {
158
+ const { client } = await create({ excludeTools: ['greet'] });
159
+
160
+ // 'greet' should NOT have proof
161
+ const greetResult = await client.callTool({
162
+ name: 'greet',
163
+ arguments: { name: 'No Proof' },
164
+ });
165
+
166
+ const greetContent = greetResult.content[0] as { type: string; text: string };
167
+ expect(greetContent.text).toBe('Hello, No Proof!');
168
+
169
+ // _meta may be undefined or proof should not be present
170
+ const greetProof = (greetResult._meta as Record<string, unknown> | undefined)?.proof;
171
+ expect(greetProof).toBeUndefined();
172
+
173
+ // 'add' should still have proof
174
+ const addResult = await client.callTool({
175
+ name: 'add',
176
+ arguments: { a: 2, b: 3 },
177
+ });
178
+
179
+ expect(addResult._meta).toBeDefined();
180
+ const addProof = (addResult._meta as Record<string, unknown>).proof as {
181
+ jws: string;
182
+ };
183
+ expect(addProof).toBeDefined();
184
+ expect(addProof.jws).toBeDefined();
185
+ });
186
+
187
+ it('proofAllTools: false disables auto-proofing', async () => {
188
+ const { client } = await create({ proofAllTools: false });
189
+
190
+ const result = await client.callTool({
191
+ name: 'greet',
192
+ arguments: { name: 'No Auto-Proof' },
193
+ });
194
+
195
+ const first = result.content[0] as { type: string; text: string };
196
+ expect(first.text).toBe('Hello, No Auto-Proof!');
197
+
198
+ // No proof — auto-proofing is off
199
+ const proof = (result._meta as Record<string, unknown> | undefined)?.proof;
200
+ expect(proof).toBeUndefined();
201
+ });
202
+
203
+ it('handshake establishes session through withMCPI', async () => {
204
+ const { client, mcpi } = await create({ autoSession: false });
205
+
206
+ const result = await client.callTool({
207
+ name: '_mcpi_handshake',
208
+ arguments: {
209
+ nonce: `test-${Date.now()}`,
210
+ audience: mcpi.identity.did,
211
+ timestamp: Math.floor(Date.now() / 1000),
212
+ },
213
+ });
214
+
215
+ const first = result.content[0] as { type: string; text: string };
216
+ const parsed = JSON.parse(first.text);
217
+ expect(parsed.success).toBe(true);
218
+ expect(parsed.sessionId).toMatch(/^mcpi_/);
219
+ expect(parsed.serverDid).toBe(mcpi.identity.did);
220
+ });
221
+
222
+ it('multiple tools share the same auto-session', async () => {
223
+ const { client } = await create();
224
+
225
+ const result1 = await client.callTool({
226
+ name: 'greet',
227
+ arguments: { name: 'Alice' },
228
+ });
229
+ const result2 = await client.callTool({
230
+ name: 'add',
231
+ arguments: { a: 1, b: 2 },
232
+ });
233
+
234
+ const proof1 = (result1._meta as Record<string, unknown>).proof as {
235
+ meta: Record<string, unknown>;
236
+ };
237
+ const proof2 = (result2._meta as Record<string, unknown>).proof as {
238
+ meta: Record<string, unknown>;
239
+ };
240
+
241
+ expect(proof1.meta.sessionId).toBe(proof2.meta.sessionId);
242
+ });
243
+
244
+ it('wrapWithDelegation still works alongside withMCPI', async () => {
245
+ const crypto = new NodeCryptoProvider();
246
+ const server = new McpServer(
247
+ { name: 'delegation-test', version: '1.0.0' },
248
+ );
249
+
250
+ const mcpi = await withMCPI(server, { crypto, autoSession: true });
251
+
252
+ // Use wrapWithDelegation for a restricted tool
253
+ const restrictedHandler = mcpi.wrapWithDelegation(
254
+ 'restricted-tool',
255
+ { scopeId: 'admin:write', consentUrl: 'https://example.com/consent' },
256
+ async (args) => ({
257
+ content: [{ type: 'text', text: `Restricted: ${(args as { data: string }).data}` }],
258
+ }),
259
+ );
260
+
261
+ server.registerTool(
262
+ 'restricted-tool',
263
+ {
264
+ description: 'Requires delegation',
265
+ inputSchema: { data: z.string() },
266
+ },
267
+ async (args) => restrictedHandler(args as Record<string, unknown>),
268
+ );
269
+
270
+ const client = new Client({ name: 'delegation-client', version: '1.0.0' });
271
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
272
+ await server.connect(serverTransport);
273
+ await client.connect(clientTransport);
274
+ pairs.push({ client, server });
275
+
276
+ // Call without delegation — should get needs_authorization
277
+ const result = await client.callTool({
278
+ name: 'restricted-tool',
279
+ arguments: { data: 'test' },
280
+ });
281
+
282
+ const first = result.content[0] as { type: string; text: string };
283
+ const parsed = JSON.parse(first.text);
284
+ expect(parsed.error).toBe('needs_authorization');
285
+ expect(parsed.authorizationUrl).toBe('https://example.com/consent');
286
+ });
287
+ });
288
+
289
+ describe('generateIdentity()', () => {
290
+ it('returns valid DID identity', async () => {
291
+ const crypto = new NodeCryptoProvider();
292
+ const identity = await generateIdentity(crypto);
293
+
294
+ expect(identity.did).toMatch(/^did:key:z6Mk/);
295
+ expect(identity.kid).toBe(`${identity.did}#keys-1`);
296
+ expect(identity.privateKey).toBeDefined();
297
+ expect(identity.publicKey).toBeDefined();
298
+ // Keys should be base64 strings
299
+ expect(() => atob(identity.privateKey)).not.toThrow();
300
+ expect(() => atob(identity.publicKey)).not.toThrow();
301
+ });
302
+
303
+ it('generates unique identities each call', async () => {
304
+ const crypto = new NodeCryptoProvider();
305
+ const id1 = await generateIdentity(crypto);
306
+ const id2 = await generateIdentity(crypto);
307
+
308
+ expect(id1.did).not.toBe(id2.did);
309
+ expect(id1.privateKey).not.toBe(id2.privateKey);
310
+ });
311
+ });