@datacules/agent-identity-mcp 0.11.0 → 0.11.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,87 @@
1
+ /**
2
+ * MCP-specific type extensions for @datacules/agent-identity.
3
+ *
4
+ * McpRequestContext extends AgentRequestContext with the MCP session and
5
+ * client identifiers so audit logs can trace a credential resolution back
6
+ * to the exact MCP session that triggered it.
7
+ */
8
+ import type { AgentRequestContext, MigrationContext } from '@datacules/agent-identity';
9
+ /**
10
+ * AgentRequestContext enriched with MCP session metadata.
11
+ * Pass this to AgentIdentityMcpServer.resolve() for full audit trail.
12
+ */
13
+ export interface McpRequestContext extends AgentRequestContext {
14
+ /** MCP session ID from the active transport session */
15
+ mcpSessionId: string;
16
+ /** MCP client identifier (e.g. 'claude-desktop', 'cursor', 'windsurf') */
17
+ mcpClientId?: string;
18
+ /** MCP protocol version negotiated during handshake */
19
+ mcpProtocolVersion?: string;
20
+ }
21
+ /**
22
+ * MigrationContext enriched with MCP session metadata.
23
+ */
24
+ export interface McpMigrationContext extends MigrationContext {
25
+ mcpSessionId: string;
26
+ mcpClientId?: string;
27
+ mcpProtocolVersion?: string;
28
+ }
29
+ export interface ResolveCredentialInput {
30
+ userId: string;
31
+ resourceId: string;
32
+ resourceKind: 'shared' | 'personal';
33
+ provider: 'openai' | 'anthropic' | 'gemini' | 'mistral' | 'local';
34
+ model: string;
35
+ action: string;
36
+ traceId: string;
37
+ sessionId?: string;
38
+ requestedAt?: string;
39
+ parentTraceId?: string;
40
+ mcpSessionId?: string;
41
+ mcpClientId?: string;
42
+ }
43
+ export interface ResolveMigrationInput {
44
+ userId: string;
45
+ resourceId: string;
46
+ resourceKind: 'shared' | 'personal';
47
+ provider: 'openai' | 'anthropic' | 'gemini' | 'mistral' | 'local';
48
+ model: string;
49
+ action: string;
50
+ traceId: string;
51
+ migrationId: string;
52
+ phase: 'dry-run' | 'extract' | 'transform' | 'load' | 'verify' | 'rollback';
53
+ sourceResourceId: string;
54
+ targetResourceId: string;
55
+ batchIndex?: number;
56
+ totalBatches?: number;
57
+ dryRun: boolean;
58
+ requestedAt?: string;
59
+ mcpSessionId?: string;
60
+ mcpClientId?: string;
61
+ }
62
+ export interface AgentIdentityMcpServerOptions {
63
+ /**
64
+ * Server name sent during MCP handshake.
65
+ * Appears in the client's tool list as the server label.
66
+ */
67
+ name?: string;
68
+ /** Semantic version string reported during handshake */
69
+ version?: string;
70
+ /**
71
+ * Transport mode.
72
+ * - 'stdio' : reads from stdin / writes to stdout (default; use for
73
+ * Claude Desktop, Claude Code, Cursor, Windsurf configs)
74
+ * - 'http' : HTTP + SSE transport on the specified port
75
+ */
76
+ transport?: 'stdio' | 'http';
77
+ /** Port for HTTP+SSE transport (default: 3002) */
78
+ httpPort?: number;
79
+ /** Host for HTTP+SSE transport (default: '127.0.0.1') */
80
+ httpHost?: string;
81
+ /**
82
+ * Optional bearer token required in MCP HTTP requests.
83
+ * Ignored for stdio transport.
84
+ */
85
+ httpAuthToken?: string;
86
+ }
87
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAIvF;;;GAGG;AACH,MAAM,WAAW,iBAAkB,SAAQ,mBAAmB;IAC5D,uDAAuD;IACvD,YAAY,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uDAAuD;IACvD,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAID,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,QAAQ,GAAG,UAAU,CAAC;IACpC,QAAQ,EAAE,QAAQ,GAAG,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;IAClE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,QAAQ,GAAG,UAAU,CAAC;IACpC,QAAQ,EAAE,QAAQ,GAAG,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;IAClE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;IAC5E,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID,MAAM,WAAW,6BAA6B;IAC5C;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB"}
package/package.json CHANGED
@@ -1,22 +1,42 @@
1
1
  {
2
2
  "name": "@datacules/agent-identity-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "description": "MCP server adapter for @datacules/agent-identity — expose credential resolution as MCP tools",
6
+ "author": "Datacules LLC",
7
+ "license": "SEE LICENSE IN LICENSE",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/hvrcharon1/agent-identity.git",
11
+ "directory": "packages/integrations/mcp"
12
+ },
6
13
  "main": "./dist/cjs/index.js",
7
14
  "module": "./dist/esm/index.js",
8
15
  "types": "./dist/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/esm/index.js",
19
+ "require": "./dist/cjs/index.js",
20
+ "types": "./dist/types/index.d.ts"
21
+ }
22
+ },
9
23
  "bin": {
10
24
  "agent-identity-mcp": "./bin/server.js"
11
25
  },
26
+ "files": [
27
+ "dist",
28
+ "bin",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
12
32
  "scripts": {
13
- "build": "tsc -p tsconfig.build.json",
33
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json",
14
34
  "type-check": "tsc --noEmit",
15
35
  "start": "node bin/server.js",
16
36
  "start:http": "MCP_TRANSPORT=http node bin/server.js"
17
37
  },
18
38
  "peerDependencies": {
19
- "@datacules/agent-identity": "^0.6.0"
39
+ "@datacules/agent-identity": "^0.11.1"
20
40
  },
21
41
  "dependencies": {
22
42
  "@modelcontextprotocol/sdk": "^1.10.0",
package/src/index.ts DELETED
@@ -1,221 +0,0 @@
1
- /**
2
- * @datacules/agent-identity-mcp
3
- *
4
- * Exposes agent-identity credential resolution as an MCP server.
5
- * Any MCP-capable client — Claude Desktop, Claude Code, Cursor,
6
- * Windsurf, or a custom agent — can call the following tools:
7
- *
8
- * resolve_credential — resolves a credential for an AgentRequestContext
9
- * resolve_migration_credential — resolves source+target pair for MigrationContext
10
- * list_credentials — lists active credentials (safe metadata only)
11
- * list_rules — lists routing rules (highest priority first)
12
- * health — liveness + loaded credential/rule counts
13
- *
14
- * Supports two transports:
15
- * stdio — stdin/stdout, compatible with Claude Desktop / Claude Code / Cursor configs
16
- * http+sse — HTTP Server-Sent Events for hosted deployments
17
- *
18
- * Quick start (stdio):
19
- * import { createAgentIdentityMcpServer } from '@datacules/agent-identity-mcp';
20
- * const { start } = createAgentIdentityMcpServer({ credentials, rules });
21
- * await start(); // reads from stdin, writes to stdout
22
- *
23
- * Quick start (HTTP):
24
- * const { start } = createAgentIdentityMcpServer({
25
- * credentials, rules, transport: 'http', httpPort: 3002
26
- * });
27
- * await start();
28
- *
29
- * Claude Desktop config snippet (~/.claude/claude_desktop_config.json):
30
- * {
31
- * "mcpServers": {
32
- * "agent-identity": {
33
- * "command": "npx",
34
- * "args": ["@datacules/agent-identity-mcp"],
35
- * "env": {
36
- * "AGENT_IDENTITY_CREDENTIALS": "<base64-encoded JSON>",
37
- * "AGENT_IDENTITY_RULES": "<base64-encoded JSON>"
38
- * }
39
- * }
40
- * }
41
- * }
42
- */
43
-
44
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
45
- import {
46
- CallToolRequestSchema,
47
- ListToolsRequestSchema,
48
- } from '@modelcontextprotocol/sdk/types.js';
49
- import type { AuditLogger, Credential, CredentialStore, RoutingRule } from '@datacules/agent-identity';
50
- import { MemoryCredentialStore } from '@datacules/agent-identity';
51
- import { ALL_TOOLS, type ToolDeps } from './tools.js';
52
- import { StdioServerTransport, startHttpMcpTransport } from './transports.js';
53
- import type { AgentIdentityMcpServerOptions } from './types.js';
54
-
55
- export * from './types.js';
56
- export { ALL_TOOLS } from './tools.js';
57
-
58
- // ─── Server factory ───────────────────────────────────────────────────────────
59
-
60
- export interface AgentIdentityMcpServerInit {
61
- /**
62
- * Credential array (convenience). Wrapped in a MemoryCredentialStore
63
- * unless `store` is also provided, in which case `store` takes precedence.
64
- */
65
- credentials?: Credential[];
66
- /** Custom CredentialStore (e.g. VaultCredentialStore, AwsCredentialStore) */
67
- store?: CredentialStore;
68
- rules: RoutingRule[];
69
- logger?: AuditLogger;
70
- }
71
-
72
- export type AgentIdentityMcpServerConfig = AgentIdentityMcpServerInit & AgentIdentityMcpServerOptions;
73
-
74
- export interface AgentIdentityMcpServerHandle {
75
- /** MCP Server instance (use to attach additional request handlers if needed) */
76
- server: Server;
77
- /** Start the server and connect the configured transport. Resolves when ready. */
78
- start: () => Promise<void>;
79
- /** Stop / clean up (closes HTTP server for http transport; no-op for stdio) */
80
- stop: () => Promise<void>;
81
- }
82
-
83
- /**
84
- * Create an agent-identity MCP server.
85
- *
86
- * @example
87
- * // stdio (Claude Desktop / Claude Code config)
88
- * const { start } = createAgentIdentityMcpServer({ credentials, rules });
89
- * await start();
90
- *
91
- * @example
92
- * // HTTP+SSE (hosted / networked)
93
- * const { start } = createAgentIdentityMcpServer({
94
- * credentials, rules, transport: 'http', httpPort: 3002,
95
- * });
96
- * await start();
97
- */
98
- export function createAgentIdentityMcpServer(
99
- config: AgentIdentityMcpServerConfig
100
- ): AgentIdentityMcpServerHandle {
101
- const {
102
- credentials,
103
- store: customStore,
104
- rules,
105
- logger,
106
- name = 'agent-identity',
107
- version = '0.1.0',
108
- transport = 'stdio',
109
- httpPort = 3002,
110
- httpHost = '127.0.0.1',
111
- httpAuthToken,
112
- } = config;
113
-
114
- if (!customStore && !credentials) {
115
- throw new Error('[agent-identity-mcp] Provide either credentials[] or a custom store.');
116
- }
117
-
118
- const store: CredentialStore =
119
- customStore ?? new MemoryCredentialStore(credentials!);
120
-
121
- const deps: ToolDeps = { store, rules, logger };
122
-
123
- // Build the MCP server
124
- const server = new Server(
125
- { name, version },
126
- { capabilities: { tools: {} } }
127
- );
128
-
129
- // Register tools/list handler
130
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
131
- tools: ALL_TOOLS.map((t) => ({
132
- name: t.name,
133
- description: t.description,
134
- inputSchema: {
135
- type: 'object' as const,
136
- // Convert Zod schema to JSON Schema via .shape introspection for
137
- // simple objects; complex schemas fall back to an open object.
138
- properties: extractJsonSchemaProperties(t.inputSchema),
139
- required: extractRequiredKeys(t.inputSchema),
140
- },
141
- })),
142
- }));
143
-
144
- // Register tools/call handler
145
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
146
- const tool = ALL_TOOLS.find((t) => t.name === request.params.name);
147
- if (!tool) {
148
- return {
149
- content: [{ type: 'text' as const, text: JSON.stringify({ error: `Unknown tool: ${request.params.name}` }) }],
150
- isError: true,
151
- };
152
- }
153
- return tool.handler(request.params.arguments ?? {}, deps);
154
- });
155
-
156
- let stopFn: (() => void) | null = null;
157
-
158
- const start = async (): Promise<void> => {
159
- if (transport === 'stdio') {
160
- const stdioTransport = new StdioServerTransport();
161
- await server.connect(stdioTransport);
162
- } else {
163
- stopFn = await startHttpMcpTransport({
164
- server,
165
- port: httpPort,
166
- host: httpHost,
167
- authToken: httpAuthToken,
168
- });
169
- }
170
- };
171
-
172
- const stop = async (): Promise<void> => {
173
- stopFn?.();
174
- await server.close();
175
- };
176
-
177
- return { server, start, stop };
178
- }
179
-
180
- // ─── JSON Schema helpers (lightweight — no ajv dependency) ───────────────────
181
-
182
- function extractJsonSchemaProperties(schema: any): Record<string, unknown> {
183
- try {
184
- const shape = schema?._def?.shape?.() ?? schema?.shape ?? {};
185
- const props: Record<string, unknown> = {};
186
- for (const [key, val] of Object.entries(shape)) {
187
- props[key] = zodToJsonSchemaNode(val);
188
- }
189
- return props;
190
- } catch {
191
- return {};
192
- }
193
- }
194
-
195
- function extractRequiredKeys(schema: any): string[] {
196
- try {
197
- const shape = schema?._def?.shape?.() ?? schema?.shape ?? {};
198
- return Object.entries(shape)
199
- .filter(([, v]: [string, any]) => {
200
- const typeName = v?._def?.typeName;
201
- return typeName !== 'ZodOptional' && typeName !== 'ZodDefault';
202
- })
203
- .map(([k]) => k);
204
- } catch {
205
- return [];
206
- }
207
- }
208
-
209
- function zodToJsonSchemaNode(zodNode: any): Record<string, unknown> {
210
- const typeName = zodNode?._def?.typeName;
211
- switch (typeName) {
212
- case 'ZodString': return { type: 'string' };
213
- case 'ZodNumber': return { type: 'number' };
214
- case 'ZodBoolean': return { type: 'boolean' };
215
- case 'ZodEnum': return { type: 'string', enum: zodNode._def.values };
216
- case 'ZodOptional':
217
- case 'ZodDefault': return zodToJsonSchemaNode(zodNode._def.innerType);
218
- case 'ZodObject': return { type: 'object', properties: extractJsonSchemaProperties(zodNode) };
219
- default: return { type: 'string' };
220
- }
221
- }
package/src/mcp.test.ts DELETED
@@ -1,271 +0,0 @@
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
- });