@datacules/agent-identity-mcp 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.test.ts +271 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datacules/agent-identity-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "MCP server adapter for @datacules/agent-identity — expose credential resolution as MCP tools",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"start:http": "MCP_TRANSPORT=http node bin/server.js"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@datacules/agent-identity": "^0.
|
|
19
|
+
"@datacules/agent-identity": "^0.6.0"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
package/src/mcp.test.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Vitest test suite for the MCP tool handler functions in
|
|
5
|
+
* packages/integrations/mcp/src/tools.ts.
|
|
6
|
+
*
|
|
7
|
+
* tools.ts imports only zod and @datacules/agent-identity — it does NOT
|
|
8
|
+
* import @modelcontextprotocol/sdk (that is in index.ts and transports.ts).
|
|
9
|
+
* Each tool's handler function is called directly with a ToolDeps object
|
|
10
|
+
* containing a MemoryCredentialStore and routing rules, so no MCP SDK runtime
|
|
11
|
+
* or network connection is required.
|
|
12
|
+
*
|
|
13
|
+
* 14 test cases:
|
|
14
|
+
* resolve_credential (4)
|
|
15
|
+
* resolve_migration_credential (3)
|
|
16
|
+
* list_credentials (3)
|
|
17
|
+
* list_rules (2)
|
|
18
|
+
* health (2)
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { ALL_TOOLS } from './tools';
|
|
22
|
+
import { MemoryCredentialStore } from '@datacules/agent-identity';
|
|
23
|
+
import type { Credential, RoutingRule } from '@datacules/agent-identity';
|
|
24
|
+
import type { ToolDeps } from './tools';
|
|
25
|
+
|
|
26
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const CREDENTIALS: Credential[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'cred-openai',
|
|
31
|
+
kind: 'fixed',
|
|
32
|
+
name: 'OpenAI Prod Key',
|
|
33
|
+
scope: 'read write',
|
|
34
|
+
status: 'active',
|
|
35
|
+
provider: 'openai',
|
|
36
|
+
ref: 'openai-prod-key',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'cred-anthropic',
|
|
40
|
+
kind: 'user-delegated',
|
|
41
|
+
name: 'Anthropic User Token',
|
|
42
|
+
scope: 'read',
|
|
43
|
+
status: 'active',
|
|
44
|
+
provider: 'anthropic',
|
|
45
|
+
ref: 'anthropic-user-ref',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const RULES: RoutingRule[] = [
|
|
50
|
+
{
|
|
51
|
+
id: 'rule-openai-shared',
|
|
52
|
+
credentialRef: 'openai-prod-key',
|
|
53
|
+
priority: 10,
|
|
54
|
+
matchProvider: 'openai',
|
|
55
|
+
matchResourceKind: 'shared',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'rule-anthropic-personal',
|
|
59
|
+
credentialRef: 'anthropic-user-ref',
|
|
60
|
+
priority: 20,
|
|
61
|
+
matchProvider: 'anthropic',
|
|
62
|
+
matchResourceKind: 'personal',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Shared base context that satisfies BaseContextSchema */
|
|
67
|
+
const BASE_CTX = {
|
|
68
|
+
userId: 'user-abc',
|
|
69
|
+
resourceId: 'res-001',
|
|
70
|
+
resourceKind: 'shared' as const,
|
|
71
|
+
provider: 'openai' as const,
|
|
72
|
+
model: 'gpt-4',
|
|
73
|
+
action: 'complete',
|
|
74
|
+
traceId: 'trace-xyz',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function makeDeps(overrideRules?: RoutingRule[]): ToolDeps {
|
|
80
|
+
const store = new MemoryCredentialStore(CREDENTIALS);
|
|
81
|
+
return { store, rules: overrideRules ?? RULES };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getTool(name: string) {
|
|
85
|
+
const tool = ALL_TOOLS.find((t) => t.name === name);
|
|
86
|
+
if (!tool) throw new Error(`Tool '${name}' not found in ALL_TOOLS`);
|
|
87
|
+
return tool;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── resolve_credential ───────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('resolve_credential tool', () => {
|
|
93
|
+
it('returns credentialId, kind, and resolvedFor on successful resolution', async () => {
|
|
94
|
+
const tool = getTool('resolve_credential');
|
|
95
|
+
const result = await tool.handler(BASE_CTX, makeDeps());
|
|
96
|
+
|
|
97
|
+
expect(result.isError).toBeFalsy();
|
|
98
|
+
const payload = JSON.parse(result.content[0].text);
|
|
99
|
+
expect(payload.ok).toBe(true);
|
|
100
|
+
expect(payload.credentialId).toBe('cred-openai');
|
|
101
|
+
expect(payload.kind).toBe('fixed');
|
|
102
|
+
expect(payload.resolvedFor).toBe('service');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never includes the raw credential ref in the response', async () => {
|
|
106
|
+
const tool = getTool('resolve_credential');
|
|
107
|
+
const result = await tool.handler(BASE_CTX, makeDeps());
|
|
108
|
+
|
|
109
|
+
const payload = JSON.parse(result.content[0].text);
|
|
110
|
+
// 'ref' must not be present — it is the raw secret reference
|
|
111
|
+
expect(payload.ref).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns isError=true when no routing rule matches the context', async () => {
|
|
115
|
+
const tool = getTool('resolve_credential');
|
|
116
|
+
// gemini matches no configured rule
|
|
117
|
+
const ctx = { ...BASE_CTX, provider: 'gemini' as const };
|
|
118
|
+
const result = await tool.handler(ctx, makeDeps());
|
|
119
|
+
|
|
120
|
+
expect(result.isError).toBe(true);
|
|
121
|
+
const payload = JSON.parse(result.content[0].text);
|
|
122
|
+
expect(payload.error).toMatch(/No credential resolved/i);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns isError=true with Zod validation error when input is invalid', async () => {
|
|
126
|
+
const tool = getTool('resolve_credential');
|
|
127
|
+
// userId is required (min length 1) — empty string fails Zod
|
|
128
|
+
const result = await tool.handler({ ...BASE_CTX, userId: '' }, makeDeps());
|
|
129
|
+
|
|
130
|
+
expect(result.isError).toBe(true);
|
|
131
|
+
const payload = JSON.parse(result.content[0].text);
|
|
132
|
+
expect(payload.error).toMatch(/Validation error/i);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── resolve_migration_credential ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('resolve_migration_credential tool', () => {
|
|
139
|
+
// Both source and target use provider:openai + resourceKind:shared,
|
|
140
|
+
// which matches rule-openai-shared → cred-openai for both contexts.
|
|
141
|
+
const MIGRATION_CTX = {
|
|
142
|
+
...BASE_CTX,
|
|
143
|
+
migrationId: 'mig-001',
|
|
144
|
+
phase: 'extract' as const,
|
|
145
|
+
sourceResourceId: 'src-001',
|
|
146
|
+
targetResourceId: 'tgt-001',
|
|
147
|
+
dryRun: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it('returns source, target, and migrationId on successful pair resolution', async () => {
|
|
151
|
+
const tool = getTool('resolve_migration_credential');
|
|
152
|
+
const result = await tool.handler(MIGRATION_CTX, makeDeps());
|
|
153
|
+
|
|
154
|
+
expect(result.isError).toBeFalsy();
|
|
155
|
+
const payload = JSON.parse(result.content[0].text);
|
|
156
|
+
expect(payload.ok).toBe(true);
|
|
157
|
+
expect(payload.migrationId).toBe('mig-001');
|
|
158
|
+
expect(payload.source.credentialId).toBe('cred-openai');
|
|
159
|
+
expect(payload.target.credentialId).toBe('cred-openai');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns isError=true when no credential pair can be resolved', async () => {
|
|
163
|
+
const tool = getTool('resolve_migration_credential');
|
|
164
|
+
// gemini matches no configured rule → pair returns null
|
|
165
|
+
const ctx = { ...MIGRATION_CTX, provider: 'gemini' as const };
|
|
166
|
+
const result = await tool.handler(ctx, makeDeps());
|
|
167
|
+
|
|
168
|
+
expect(result.isError).toBe(true);
|
|
169
|
+
const payload = JSON.parse(result.content[0].text);
|
|
170
|
+
expect(payload.error).toMatch(/No credential pair resolved/i);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns isError=true with Zod validation error when migrationId is missing', async () => {
|
|
174
|
+
const tool = getTool('resolve_migration_credential');
|
|
175
|
+
// Destructure out migrationId so the field is absent from the input object
|
|
176
|
+
const { migrationId: _omit, ...withoutMigrationId } = MIGRATION_CTX;
|
|
177
|
+
const result = await tool.handler(withoutMigrationId, makeDeps());
|
|
178
|
+
|
|
179
|
+
expect(result.isError).toBe(true);
|
|
180
|
+
const payload = JSON.parse(result.content[0].text);
|
|
181
|
+
expect(payload.error).toMatch(/Validation error/i);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── list_credentials ─────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe('list_credentials tool', () => {
|
|
188
|
+
it('returns all active credentials with safe metadata (no raw ref)', async () => {
|
|
189
|
+
const tool = getTool('list_credentials');
|
|
190
|
+
const result = await tool.handler({}, makeDeps());
|
|
191
|
+
|
|
192
|
+
expect(result.isError).toBeFalsy();
|
|
193
|
+
const payload = JSON.parse(result.content[0].text);
|
|
194
|
+
expect(payload.count).toBe(2);
|
|
195
|
+
expect(payload.credentials[0]).toHaveProperty('id');
|
|
196
|
+
expect(payload.credentials[0]).toHaveProperty('kind');
|
|
197
|
+
expect(payload.credentials[0]).toHaveProperty('scope');
|
|
198
|
+
// raw ref must never appear in list output
|
|
199
|
+
expect(payload.credentials[0]).not.toHaveProperty('ref');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('filters to only fixed credentials when kind=fixed is specified', async () => {
|
|
203
|
+
const tool = getTool('list_credentials');
|
|
204
|
+
const result = await tool.handler({ kind: 'fixed' }, makeDeps());
|
|
205
|
+
|
|
206
|
+
const payload = JSON.parse(result.content[0].text);
|
|
207
|
+
expect(payload.count).toBe(1);
|
|
208
|
+
expect(payload.credentials[0].kind).toBe('fixed');
|
|
209
|
+
expect(payload.credentials[0].id).toBe('cred-openai');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('filters to only user-delegated credentials when kind=user-delegated is specified', async () => {
|
|
213
|
+
const tool = getTool('list_credentials');
|
|
214
|
+
const result = await tool.handler({ kind: 'user-delegated' }, makeDeps());
|
|
215
|
+
|
|
216
|
+
const payload = JSON.parse(result.content[0].text);
|
|
217
|
+
expect(payload.count).toBe(1);
|
|
218
|
+
expect(payload.credentials[0].kind).toBe('user-delegated');
|
|
219
|
+
expect(payload.credentials[0].id).toBe('cred-anthropic');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ─── list_rules ───────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe('list_rules tool', () => {
|
|
226
|
+
it('returns all rules sorted by priority descending', async () => {
|
|
227
|
+
const tool = getTool('list_rules');
|
|
228
|
+
const result = await tool.handler({}, makeDeps());
|
|
229
|
+
|
|
230
|
+
expect(result.isError).toBeFalsy();
|
|
231
|
+
const payload = JSON.parse(result.content[0].text);
|
|
232
|
+
expect(payload.count).toBe(2);
|
|
233
|
+
// rule-anthropic-personal (priority 20) must come before rule-openai-shared (priority 10)
|
|
234
|
+
expect(payload.rules[0].priority).toBeGreaterThanOrEqual(payload.rules[1].priority);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('includes both rule ids in the result', async () => {
|
|
238
|
+
const tool = getTool('list_rules');
|
|
239
|
+
const result = await tool.handler({}, makeDeps());
|
|
240
|
+
|
|
241
|
+
const payload = JSON.parse(result.content[0].text);
|
|
242
|
+
const ids: string[] = payload.rules.map((r: { id: string }) => r.id);
|
|
243
|
+
expect(ids).toContain('rule-openai-shared');
|
|
244
|
+
expect(ids).toContain('rule-anthropic-personal');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ─── health ───────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe('health tool', () => {
|
|
251
|
+
it('returns status: ok, credentialsLoaded, rulesLoaded, and a timestamp', async () => {
|
|
252
|
+
const tool = getTool('health');
|
|
253
|
+
const result = await tool.handler({}, makeDeps());
|
|
254
|
+
|
|
255
|
+
expect(result.isError).toBeFalsy();
|
|
256
|
+
const payload = JSON.parse(result.content[0].text);
|
|
257
|
+
expect(payload.status).toBe('ok');
|
|
258
|
+
expect(payload.credentialsLoaded).toBe(2);
|
|
259
|
+
expect(payload.rulesLoaded).toBe(2);
|
|
260
|
+
expect(payload.timestamp).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('timestamp in health response is a valid ISO 8601 string', async () => {
|
|
264
|
+
const tool = getTool('health');
|
|
265
|
+
const result = await tool.handler({}, makeDeps());
|
|
266
|
+
|
|
267
|
+
const payload = JSON.parse(result.content[0].text);
|
|
268
|
+
// new Date().toISOString() must not throw and must round-trip cleanly
|
|
269
|
+
expect(() => new Date(payload.timestamp).toISOString()).not.toThrow();
|
|
270
|
+
});
|
|
271
|
+
});
|