@datacules/agent-identity-mcp-client 0.5.0 → 0.7.0
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 +2 -2
- package/src/mcp-client.test.ts +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datacules/agent-identity-mcp-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "MCP client adapter for @datacules/agent-identity — consume external MCP servers as CredentialStores",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type-check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@datacules/agent-identity": "^0.
|
|
14
|
+
"@datacules/agent-identity": "^0.6.0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.10.0"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock MCP SDK modules — the lazy-connect pattern means these are only imported
|
|
4
|
+
// at the module level but never called at runtime if we inject mock clients directly.
|
|
5
|
+
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
6
|
+
Client: vi.fn(() => ({ callTool: vi.fn(), close: vi.fn(), connect: vi.fn() })),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
|
9
|
+
SSEClientTransport: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|
12
|
+
StdioClientTransport: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { McpCredentialStore } from './store.js';
|
|
16
|
+
import { McpToolCaller } from './caller.js';
|
|
17
|
+
import type { Credential } from '@datacules/agent-identity';
|
|
18
|
+
|
|
19
|
+
const makeCred = (overrides: Partial<Credential> = {}): Credential => ({
|
|
20
|
+
id: 'cred-openai',
|
|
21
|
+
kind: 'fixed',
|
|
22
|
+
name: 'OpenAI Key',
|
|
23
|
+
scope: 'global',
|
|
24
|
+
status: 'active',
|
|
25
|
+
provider: 'openai',
|
|
26
|
+
ref: 'openai-prod-slot',
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Build the content array shape that McpCredentialStore / McpToolCaller expect from callTool() */
|
|
31
|
+
const makeToolResult = (data: unknown) => ({
|
|
32
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── McpCredentialStore ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('McpCredentialStore', () => {
|
|
38
|
+
let store: McpCredentialStore;
|
|
39
|
+
let mockCallTool: ReturnType<typeof vi.fn>;
|
|
40
|
+
let mockClose: ReturnType<typeof vi.fn>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
mockCallTool = vi.fn();
|
|
45
|
+
mockClose = vi.fn();
|
|
46
|
+
store = new McpCredentialStore({ transport: 'http', serverUrl: 'http://localhost:3002' });
|
|
47
|
+
// Inject a mock client — ensureConnected() checks `this.client` first, so _connect() is never called.
|
|
48
|
+
(store as any).client = { callTool: mockCallTool, close: mockClose };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('listActive()', () => {
|
|
52
|
+
it('returns only active credentials from the MCP server list_credentials response', async () => {
|
|
53
|
+
const active = makeCred({ id: 'cred-active', ref: 'active-slot' });
|
|
54
|
+
const pending = makeCred({ id: 'cred-pending', ref: 'pending-slot', status: 'pending' });
|
|
55
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [active, pending] }));
|
|
56
|
+
const results = await store.listActive();
|
|
57
|
+
expect(results).toHaveLength(1);
|
|
58
|
+
expect(results[0]).toEqual(active);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('caches results — calls list_credentials tool only once for two listActive() calls', async () => {
|
|
62
|
+
const cred = makeCred();
|
|
63
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [cred] }));
|
|
64
|
+
await store.listActive();
|
|
65
|
+
await store.listActive();
|
|
66
|
+
// Second call hits cache; callTool should be called exactly once.
|
|
67
|
+
expect(mockCallTool).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('invalidateCache() forces a fresh fetch on the next listActive() call', async () => {
|
|
71
|
+
const cred = makeCred();
|
|
72
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [cred] }));
|
|
73
|
+
await store.listActive();
|
|
74
|
+
store.invalidateCache();
|
|
75
|
+
await store.listActive();
|
|
76
|
+
// Cache was cleared; callTool should have been called twice.
|
|
77
|
+
expect(mockCallTool).toHaveBeenCalledTimes(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws with a non-JSON message when the server returns unparseable text', async () => {
|
|
81
|
+
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'not-json-at-all' }] });
|
|
82
|
+
await expect(store.listActive()).rejects.toThrow('non-JSON response');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws with a missing-credentials-array message when the response has no credentials field', async () => {
|
|
86
|
+
mockCallTool.mockResolvedValue(makeToolResult({ data: [] }));
|
|
87
|
+
await expect(store.listActive()).rejects.toThrow('missing credentials array');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('findByRef()', () => {
|
|
92
|
+
it('returns the matching active credential by ref', async () => {
|
|
93
|
+
const cred = makeCred({ ref: 'openai-prod-slot' });
|
|
94
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [cred] }));
|
|
95
|
+
expect(await store.findByRef('openai-prod-slot')).toEqual(cred);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns null when the ref is not present in the server credential list', async () => {
|
|
99
|
+
const cred = makeCred({ ref: 'other-slot' });
|
|
100
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [cred] }));
|
|
101
|
+
expect(await store.findByRef('missing-ref')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('listByKind()', () => {
|
|
106
|
+
it('returns only credentials matching the requested kind', async () => {
|
|
107
|
+
const fixed = makeCred({ kind: 'fixed', id: 'cred-f', ref: 'fixed-slot' });
|
|
108
|
+
const delegated = makeCred({ kind: 'user-delegated', id: 'cred-u', ref: 'user-slot' });
|
|
109
|
+
mockCallTool.mockResolvedValue(makeToolResult({ credentials: [fixed, delegated] }));
|
|
110
|
+
const result = await store.listByKind('fixed');
|
|
111
|
+
expect(result).toHaveLength(1);
|
|
112
|
+
expect(result[0].kind).toBe('fixed');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('disconnect()', () => {
|
|
117
|
+
it('calls close() on the injected client and sets this.client to null', async () => {
|
|
118
|
+
await store.disconnect();
|
|
119
|
+
expect(mockClose).toHaveBeenCalled();
|
|
120
|
+
expect((store as any).client).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─── McpToolCaller ────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe('McpToolCaller', () => {
|
|
128
|
+
let caller: McpToolCaller;
|
|
129
|
+
let mockCallTool: ReturnType<typeof vi.fn>;
|
|
130
|
+
let mockClose: ReturnType<typeof vi.fn>;
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
vi.clearAllMocks();
|
|
134
|
+
mockCallTool = vi.fn();
|
|
135
|
+
mockClose = vi.fn();
|
|
136
|
+
caller = new McpToolCaller({ transport: 'http', serverUrl: 'http://localhost:3002' });
|
|
137
|
+
// Inject a mock client — ensureConnected() short-circuits on this.client
|
|
138
|
+
(caller as any).client = { callTool: mockCallTool, close: mockClose };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('resolveCredential() calls the resolve_credential tool with forwarded args and returns parsed result', async () => {
|
|
142
|
+
const expected = { ok: true, credentialId: 'cred-1', kind: 'fixed', resolvedFor: 'service' };
|
|
143
|
+
mockCallTool.mockResolvedValue(makeToolResult(expected));
|
|
144
|
+
const result = await caller.resolveCredential({ userId: 'u1', provider: 'openai' });
|
|
145
|
+
expect(result).toEqual(expected);
|
|
146
|
+
expect(mockCallTool).toHaveBeenCalledWith({
|
|
147
|
+
name: 'resolve_credential',
|
|
148
|
+
arguments: { userId: 'u1', provider: 'openai' },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('resolveMigrationCredential() calls the resolve_migration_credential tool and returns pair', async () => {
|
|
153
|
+
const expected = {
|
|
154
|
+
ok: true,
|
|
155
|
+
migrationId: 'mig-1',
|
|
156
|
+
source: { credentialId: 'src' },
|
|
157
|
+
target: { credentialId: 'tgt' },
|
|
158
|
+
expiresAt: null,
|
|
159
|
+
};
|
|
160
|
+
mockCallTool.mockResolvedValue(makeToolResult(expected));
|
|
161
|
+
const result = await caller.resolveMigrationCredential({ migrationId: 'mig-1' });
|
|
162
|
+
expect(result.migrationId).toBe('mig-1');
|
|
163
|
+
expect(mockCallTool).toHaveBeenCalledWith(
|
|
164
|
+
expect.objectContaining({ name: 'resolve_migration_credential' })
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('health() calls the health tool and returns the status object', async () => {
|
|
169
|
+
const expected = {
|
|
170
|
+
status: 'ok',
|
|
171
|
+
credentialsLoaded: 5,
|
|
172
|
+
rulesLoaded: 3,
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
mockCallTool.mockResolvedValue(makeToolResult(expected));
|
|
176
|
+
const result = await caller.health();
|
|
177
|
+
expect(result.status).toBe('ok');
|
|
178
|
+
expect(mockCallTool).toHaveBeenCalledWith({ name: 'health', arguments: {} });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('callTool() generic escape hatch returns the parsed result for any tool', async () => {
|
|
182
|
+
mockCallTool.mockResolvedValue(makeToolResult({ foo: 'bar', count: 42 }));
|
|
183
|
+
const result = await caller.callTool('my_custom_tool', { arg: 1 });
|
|
184
|
+
expect(result).toEqual({ foo: 'bar', count: 42 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('throws with a non-JSON error when the tool returns unparseable text', async () => {
|
|
188
|
+
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'NOT JSON!' }] });
|
|
189
|
+
await expect(caller.callTool('my_tool', {})).rejects.toThrow('non-JSON');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('throws with the tool error message when the parsed result contains an error field', async () => {
|
|
193
|
+
mockCallTool.mockResolvedValue(makeToolResult({ error: 'credential not found' }));
|
|
194
|
+
await expect(caller.callTool('resolve_credential', {})).rejects.toThrow('credential not found');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('disconnect() calls close() on the injected client', async () => {
|
|
198
|
+
await caller.disconnect();
|
|
199
|
+
expect(mockClose).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
});
|