@operor/skills 0.1.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/dist/index.d.ts +203 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +348 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
- package/skill-catalog.json +465 -0
- package/src/MCPSkill.ts +154 -0
- package/src/PromptSkill.ts +38 -0
- package/src/SkillManager.ts +98 -0
- package/src/__tests__/MCPSkill.test.ts +308 -0
- package/src/__tests__/SkillManager.test.ts +172 -0
- package/src/__tests__/catalog.test.ts +383 -0
- package/src/__tests__/config.test.ts +181 -0
- package/src/catalog.ts +213 -0
- package/src/config.ts +95 -0
- package/src/index.ts +30 -0
- package/test/catalog.test.ts +221 -0
- package/test/config.test.ts +162 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
loadSkillCatalogFrom,
|
|
8
|
+
querySkillCatalog,
|
|
9
|
+
findSkillInCatalog,
|
|
10
|
+
catalogEntryToConfig,
|
|
11
|
+
} from '../catalog.js';
|
|
12
|
+
import type { SkillCatalog, SkillCatalogEntry } from '../catalog.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
// ─── Test Data ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function makeCatalog(skills: SkillCatalogEntry[]): SkillCatalog {
|
|
19
|
+
return { version: 1, updatedAt: '2026-01-01T00:00:00.000Z', skills };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const shopifyEntry: SkillCatalogEntry = {
|
|
23
|
+
name: 'shopify',
|
|
24
|
+
displayName: 'Shopify',
|
|
25
|
+
description: 'Manage Shopify store — products, orders, customers',
|
|
26
|
+
category: 'commerce',
|
|
27
|
+
tags: ['ecommerce', 'shopify', 'orders'],
|
|
28
|
+
transport: 'stdio',
|
|
29
|
+
package: 'shopify-mcp',
|
|
30
|
+
command: 'npx',
|
|
31
|
+
args: ['-y', 'shopify-mcp'],
|
|
32
|
+
envVars: {
|
|
33
|
+
SHOPIFY_ACCESS_TOKEN: {
|
|
34
|
+
description: 'Shopify Admin API access token',
|
|
35
|
+
required: true,
|
|
36
|
+
placeholder: 'shpat_...',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
tools: [
|
|
40
|
+
{ name: 'get_products', description: 'List products' },
|
|
41
|
+
{ name: 'get_orders', description: 'List orders' },
|
|
42
|
+
],
|
|
43
|
+
maturity: 'official',
|
|
44
|
+
vendor: 'Shopify',
|
|
45
|
+
docsUrl: 'https://github.com/Shopify/shopify-mcp',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const stripeEntry: SkillCatalogEntry = {
|
|
49
|
+
name: 'stripe',
|
|
50
|
+
displayName: 'Stripe',
|
|
51
|
+
description: 'Process payments and manage subscriptions via Stripe',
|
|
52
|
+
category: 'payments',
|
|
53
|
+
tags: ['payments', 'stripe', 'billing'],
|
|
54
|
+
transport: 'stdio',
|
|
55
|
+
package: '@stripe/mcp',
|
|
56
|
+
command: 'npx',
|
|
57
|
+
args: ['-y', '@stripe/mcp', '--tools=all'],
|
|
58
|
+
envVars: {
|
|
59
|
+
STRIPE_SECRET_KEY: {
|
|
60
|
+
description: 'Stripe secret API key',
|
|
61
|
+
required: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
tools: [
|
|
65
|
+
{ name: 'create_payment_intent', description: 'Create a payment' },
|
|
66
|
+
],
|
|
67
|
+
maturity: 'official',
|
|
68
|
+
vendor: 'Stripe',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const searchEntry: SkillCatalogEntry = {
|
|
72
|
+
name: 'search',
|
|
73
|
+
displayName: 'Brave Search',
|
|
74
|
+
description: 'Web search via Brave Search API',
|
|
75
|
+
category: 'search',
|
|
76
|
+
tags: ['search', 'web', 'brave'],
|
|
77
|
+
transport: 'stdio',
|
|
78
|
+
package: '@modelcontextprotocol/server-brave-search',
|
|
79
|
+
envVars: {
|
|
80
|
+
BRAVE_API_KEY: { description: 'Brave API key', required: true },
|
|
81
|
+
},
|
|
82
|
+
tools: [
|
|
83
|
+
{ name: 'brave_web_search', description: 'Search the web' },
|
|
84
|
+
],
|
|
85
|
+
maturity: 'official',
|
|
86
|
+
vendor: 'Anthropic',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const httpEntry: SkillCatalogEntry = {
|
|
90
|
+
name: 'remote-api',
|
|
91
|
+
displayName: 'Remote API',
|
|
92
|
+
description: 'A remote HTTP-based skill',
|
|
93
|
+
category: 'other',
|
|
94
|
+
tags: ['remote', 'api'],
|
|
95
|
+
transport: 'http',
|
|
96
|
+
url: 'https://api.example.com/mcp',
|
|
97
|
+
envVars: {},
|
|
98
|
+
tools: [{ name: 'call_api', description: 'Call the API' }],
|
|
99
|
+
maturity: 'community',
|
|
100
|
+
vendor: 'Example Inc',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ─── loadSkillCatalogFrom ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('loadSkillCatalogFrom', () => {
|
|
106
|
+
let tmpDir: string;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
tmpDir = join(tmpdir(), `catalog-test-${Date.now()}`);
|
|
110
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('loads a valid catalog JSON file', () => {
|
|
118
|
+
const catalog = makeCatalog([shopifyEntry]);
|
|
119
|
+
const filePath = join(tmpDir, 'catalog.json');
|
|
120
|
+
writeFileSync(filePath, JSON.stringify(catalog));
|
|
121
|
+
|
|
122
|
+
const loaded = loadSkillCatalogFrom(filePath);
|
|
123
|
+
expect(loaded.version).toBe(1);
|
|
124
|
+
expect(loaded.skills).toHaveLength(1);
|
|
125
|
+
expect(loaded.skills[0].name).toBe('shopify');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('throws on invalid JSON', () => {
|
|
129
|
+
const filePath = join(tmpDir, 'bad.json');
|
|
130
|
+
writeFileSync(filePath, '{ not valid json !!!');
|
|
131
|
+
expect(() => loadSkillCatalogFrom(filePath)).toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws on missing file', () => {
|
|
135
|
+
expect(() => loadSkillCatalogFrom(join(tmpDir, 'nope.json'))).toThrow();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── querySkillCatalog ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('querySkillCatalog', () => {
|
|
142
|
+
const catalog = makeCatalog([shopifyEntry, stripeEntry, searchEntry, httpEntry]);
|
|
143
|
+
|
|
144
|
+
it('returns all skills when no filter is provided', () => {
|
|
145
|
+
const results = querySkillCatalog(catalog);
|
|
146
|
+
expect(results).toHaveLength(4);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns all skills with empty filter object', () => {
|
|
150
|
+
const results = querySkillCatalog(catalog, {});
|
|
151
|
+
expect(results).toHaveLength(4);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('filters by category', () => {
|
|
155
|
+
const results = querySkillCatalog(catalog, { category: 'commerce' });
|
|
156
|
+
expect(results).toHaveLength(1);
|
|
157
|
+
expect(results[0].name).toBe('shopify');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns empty for non-matching category', () => {
|
|
161
|
+
const results = querySkillCatalog(catalog, { category: 'crm' });
|
|
162
|
+
expect(results).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('filters by maturity', () => {
|
|
166
|
+
const results = querySkillCatalog(catalog, { maturity: 'community' });
|
|
167
|
+
expect(results).toHaveLength(1);
|
|
168
|
+
expect(results[0].name).toBe('remote-api');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('filters by tags', () => {
|
|
172
|
+
const results = querySkillCatalog(catalog, { tags: ['payments'] });
|
|
173
|
+
expect(results).toHaveLength(1);
|
|
174
|
+
expect(results[0].name).toBe('stripe');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('filters by multiple tags (OR logic)', () => {
|
|
178
|
+
const results = querySkillCatalog(catalog, { tags: ['ecommerce', 'web'] });
|
|
179
|
+
expect(results).toHaveLength(2);
|
|
180
|
+
const names = results.map(r => r.name);
|
|
181
|
+
expect(names).toContain('shopify');
|
|
182
|
+
expect(names).toContain('search');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('searches by name', () => {
|
|
186
|
+
const results = querySkillCatalog(catalog, { search: 'shopify' });
|
|
187
|
+
expect(results).toHaveLength(1);
|
|
188
|
+
expect(results[0].name).toBe('shopify');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('searches by displayName', () => {
|
|
192
|
+
const results = querySkillCatalog(catalog, { search: 'Brave' });
|
|
193
|
+
expect(results).toHaveLength(1);
|
|
194
|
+
expect(results[0].name).toBe('search');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('searches by description', () => {
|
|
198
|
+
const results = querySkillCatalog(catalog, { search: 'subscriptions' });
|
|
199
|
+
expect(results).toHaveLength(1);
|
|
200
|
+
expect(results[0].name).toBe('stripe');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('search is case-insensitive', () => {
|
|
204
|
+
const results = querySkillCatalog(catalog, { search: 'SHOPIFY' });
|
|
205
|
+
expect(results).toHaveLength(1);
|
|
206
|
+
expect(results[0].name).toBe('shopify');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('combines category + search filters', () => {
|
|
210
|
+
// Both shopify and stripe are official, but only shopify matches "orders"
|
|
211
|
+
const results = querySkillCatalog(catalog, { maturity: 'official', search: 'orders' });
|
|
212
|
+
expect(results).toHaveLength(1);
|
|
213
|
+
expect(results[0].name).toBe('shopify');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns empty when combined filters match nothing', () => {
|
|
217
|
+
const results = querySkillCatalog(catalog, { category: 'commerce', search: 'payments' });
|
|
218
|
+
expect(results).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('searches tags too', () => {
|
|
222
|
+
const results = querySkillCatalog(catalog, { search: 'billing' });
|
|
223
|
+
expect(results).toHaveLength(1);
|
|
224
|
+
expect(results[0].name).toBe('stripe');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── findSkillInCatalog ─────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe('findSkillInCatalog', () => {
|
|
231
|
+
const catalog = makeCatalog([shopifyEntry, stripeEntry]);
|
|
232
|
+
|
|
233
|
+
it('finds a skill by exact name', () => {
|
|
234
|
+
const result = findSkillInCatalog(catalog, 'shopify');
|
|
235
|
+
expect(result).toBeDefined();
|
|
236
|
+
expect(result!.displayName).toBe('Shopify');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('returns undefined for non-existent skill', () => {
|
|
240
|
+
const result = findSkillInCatalog(catalog, 'nonexistent');
|
|
241
|
+
expect(result).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('is case-sensitive', () => {
|
|
245
|
+
const result = findSkillInCatalog(catalog, 'Shopify');
|
|
246
|
+
expect(result).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ─── catalogEntryToConfig ───────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
describe('catalogEntryToConfig', () => {
|
|
253
|
+
it('converts a stdio skill with package', () => {
|
|
254
|
+
const config = catalogEntryToConfig(shopifyEntry);
|
|
255
|
+
expect(config.name).toBe('shopify');
|
|
256
|
+
expect(config.transport).toBe('stdio');
|
|
257
|
+
expect(config.command).toBe('npx');
|
|
258
|
+
expect(config.args).toEqual(['-y', 'shopify-mcp']);
|
|
259
|
+
expect(config.enabled).toBe(true);
|
|
260
|
+
expect(config.env).toEqual({ SHOPIFY_ACCESS_TOKEN: '${SHOPIFY_ACCESS_TOKEN}' });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('converts a stdio skill with explicit args', () => {
|
|
264
|
+
const config = catalogEntryToConfig(stripeEntry);
|
|
265
|
+
expect(config.args).toEqual(['-y', '@stripe/mcp', '--tools=all']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('converts a stdio skill without explicit command (defaults to npx)', () => {
|
|
269
|
+
const config = catalogEntryToConfig(searchEntry);
|
|
270
|
+
expect(config.command).toBe('npx');
|
|
271
|
+
expect(config.args).toEqual(['-y', '@modelcontextprotocol/server-brave-search']);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('converts an http skill with url', () => {
|
|
275
|
+
const config = catalogEntryToConfig(httpEntry);
|
|
276
|
+
expect(config.transport).toBe('http');
|
|
277
|
+
expect(config.url).toBe('https://api.example.com/mcp');
|
|
278
|
+
expect(config.command).toBeUndefined();
|
|
279
|
+
expect(config.args).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('omits env when no envVars', () => {
|
|
283
|
+
const config = catalogEntryToConfig(httpEntry);
|
|
284
|
+
expect(config.env).toBeUndefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('maps multiple env vars to placeholder syntax', () => {
|
|
288
|
+
const entry: SkillCatalogEntry = {
|
|
289
|
+
...shopifyEntry,
|
|
290
|
+
envVars: {
|
|
291
|
+
TOKEN_A: { description: 'Token A', required: true },
|
|
292
|
+
TOKEN_B: { description: 'Token B', required: false },
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
const config = catalogEntryToConfig(entry);
|
|
296
|
+
expect(config.env).toEqual({
|
|
297
|
+
TOKEN_A: '${TOKEN_A}',
|
|
298
|
+
TOKEN_B: '${TOKEN_B}',
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('converts an sse skill with url', () => {
|
|
303
|
+
const sseEntry: SkillCatalogEntry = {
|
|
304
|
+
...httpEntry,
|
|
305
|
+
name: 'sse-skill',
|
|
306
|
+
transport: 'sse',
|
|
307
|
+
url: 'https://sse.example.com/events',
|
|
308
|
+
};
|
|
309
|
+
const config = catalogEntryToConfig(sseEntry);
|
|
310
|
+
expect(config.transport).toBe('sse');
|
|
311
|
+
expect(config.url).toBe('https://sse.example.com/events');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ─── Shipped skill-catalog.json Integration ─────────────────────────────────
|
|
316
|
+
|
|
317
|
+
describe('shipped skill-catalog.json', () => {
|
|
318
|
+
const catalogPath = resolve(__dirname, '..', '..', 'skill-catalog.json');
|
|
319
|
+
|
|
320
|
+
it('file exists at expected path', () => {
|
|
321
|
+
expect(existsSync(catalogPath)).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('loads and parses without errors', () => {
|
|
325
|
+
const catalog = loadSkillCatalogFrom(catalogPath);
|
|
326
|
+
expect(catalog.version).toBe(1);
|
|
327
|
+
expect(catalog.updatedAt).toBeDefined();
|
|
328
|
+
expect(Array.isArray(catalog.skills)).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('contains at least one skill', () => {
|
|
332
|
+
const catalog = loadSkillCatalogFrom(catalogPath);
|
|
333
|
+
expect(catalog.skills.length).toBeGreaterThan(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('every entry has required fields', () => {
|
|
337
|
+
const catalog = loadSkillCatalogFrom(catalogPath);
|
|
338
|
+
for (const skill of catalog.skills) {
|
|
339
|
+
expect(skill.name).toBeTruthy();
|
|
340
|
+
expect(skill.displayName).toBeTruthy();
|
|
341
|
+
expect(skill.description).toBeTruthy();
|
|
342
|
+
expect(skill.category).toBeTruthy();
|
|
343
|
+
expect(skill.maturity).toMatch(/^(official|community|experimental)$/);
|
|
344
|
+
expect(skill.vendor).toBeTruthy();
|
|
345
|
+
expect(Array.isArray(skill.tags)).toBe(true);
|
|
346
|
+
if ((skill as any).type === 'prompt') {
|
|
347
|
+
expect((skill as any).content).toBeTruthy();
|
|
348
|
+
} else {
|
|
349
|
+
expect((skill as any).transport).toMatch(/^(stdio|http|sse)$/);
|
|
350
|
+
expect(Array.isArray((skill as any).tools)).toBe(true);
|
|
351
|
+
expect((skill as any).tools.length).toBeGreaterThan(0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('every entry converts to a valid config', () => {
|
|
357
|
+
const catalog = loadSkillCatalogFrom(catalogPath);
|
|
358
|
+
for (const skill of catalog.skills) {
|
|
359
|
+
const config = catalogEntryToConfig(skill);
|
|
360
|
+
expect(config.name).toBe(skill.name);
|
|
361
|
+
expect(config.enabled).toBe(true);
|
|
362
|
+
if ((skill as any).type === 'prompt') {
|
|
363
|
+
expect(config.type).toBe('prompt');
|
|
364
|
+
expect(config.content).toBeTruthy();
|
|
365
|
+
} else {
|
|
366
|
+
expect(config.transport).toBe((skill as any).transport);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('query helpers work against real data', () => {
|
|
372
|
+
const catalog = loadSkillCatalogFrom(catalogPath);
|
|
373
|
+
// findSkillInCatalog should find the first skill by name
|
|
374
|
+
const first = catalog.skills[0];
|
|
375
|
+
const found = findSkillInCatalog(catalog, first.name);
|
|
376
|
+
expect(found).toBeDefined();
|
|
377
|
+
expect(found!.name).toBe(first.name);
|
|
378
|
+
|
|
379
|
+
// querySkillCatalog with no filter returns all
|
|
380
|
+
const all = querySkillCatalog(catalog);
|
|
381
|
+
expect(all).toHaveLength(catalog.skills.length);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveEnvVars, resolveEnvRecord, loadSkillsConfig } from '../config.js';
|
|
3
|
+
import { SkillManager } from '../SkillManager.js';
|
|
4
|
+
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
describe('resolveEnvVars', () => {
|
|
9
|
+
const originalEnv = process.env;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
process.env = { ...originalEnv };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
process.env = originalEnv;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('resolves a single env var', () => {
|
|
20
|
+
process.env.MY_TOKEN = 'secret123';
|
|
21
|
+
expect(resolveEnvVars('Bearer ${MY_TOKEN}')).toBe('Bearer secret123');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('resolves multiple env vars', () => {
|
|
25
|
+
process.env.HOST = 'localhost';
|
|
26
|
+
process.env.PORT = '3000';
|
|
27
|
+
expect(resolveEnvVars('http://${HOST}:${PORT}')).toBe('http://localhost:3000');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns empty string for missing env vars', () => {
|
|
31
|
+
expect(resolveEnvVars('${DOES_NOT_EXIST}')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('passes through strings without env vars', () => {
|
|
35
|
+
expect(resolveEnvVars('plain text')).toBe('plain text');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('resolveEnvRecord', () => {
|
|
40
|
+
const originalEnv = process.env;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
process.env = { ...originalEnv };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
process.env = originalEnv;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('resolves all values in a record', () => {
|
|
51
|
+
process.env.API_KEY = 'key123';
|
|
52
|
+
process.env.SECRET = 'sec456';
|
|
53
|
+
const result = resolveEnvRecord({
|
|
54
|
+
Authorization: 'Bearer ${API_KEY}',
|
|
55
|
+
'X-Secret': '${SECRET}',
|
|
56
|
+
});
|
|
57
|
+
expect(result).toEqual({
|
|
58
|
+
Authorization: 'Bearer key123',
|
|
59
|
+
'X-Secret': 'sec456',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('loadSkillsConfig', () => {
|
|
65
|
+
let tmpDir: string;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
tmpDir = join(tmpdir(), `skills-test-${Date.now()}`);
|
|
69
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns empty skills when no mcp.json exists', () => {
|
|
77
|
+
const config = loadSkillsConfig(tmpDir);
|
|
78
|
+
expect(config.skills).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('loads skills from mcp.json', () => {
|
|
82
|
+
const mcpConfig = {
|
|
83
|
+
skills: [
|
|
84
|
+
{
|
|
85
|
+
name: 'shopify',
|
|
86
|
+
transport: 'stdio',
|
|
87
|
+
command: 'npx',
|
|
88
|
+
args: ['-y', 'shopify-mcp'],
|
|
89
|
+
env: { SHOPIFY_TOKEN: '${SHOPIFY_ACCESS_TOKEN}' },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'search',
|
|
93
|
+
transport: 'http',
|
|
94
|
+
url: 'https://search.example.com/mcp',
|
|
95
|
+
enabled: false,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
writeFileSync(join(tmpDir, 'mcp.json'), JSON.stringify(mcpConfig));
|
|
100
|
+
|
|
101
|
+
const config = loadSkillsConfig(tmpDir);
|
|
102
|
+
expect(config.skills).toHaveLength(2);
|
|
103
|
+
expect(config.skills[0].name).toBe('shopify');
|
|
104
|
+
expect(config.skills[0].transport).toBe('stdio');
|
|
105
|
+
expect(config.skills[1].enabled).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles empty mcp.json', () => {
|
|
109
|
+
writeFileSync(join(tmpDir, 'mcp.json'), '{}');
|
|
110
|
+
const config = loadSkillsConfig(tmpDir);
|
|
111
|
+
expect(config.skills).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('throws on invalid JSON', () => {
|
|
115
|
+
writeFileSync(join(tmpDir, 'mcp.json'), '{ broken json !!!');
|
|
116
|
+
expect(() => loadSkillsConfig(tmpDir)).toThrow('Invalid JSON');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('SkillManager.validateConfig', () => {
|
|
121
|
+
it('returns null for valid stdio config', () => {
|
|
122
|
+
expect(SkillManager.validateConfig({
|
|
123
|
+
name: 'test',
|
|
124
|
+
transport: 'stdio',
|
|
125
|
+
command: 'node',
|
|
126
|
+
args: ['server.js'],
|
|
127
|
+
})).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns null for valid http config', () => {
|
|
131
|
+
expect(SkillManager.validateConfig({
|
|
132
|
+
name: 'test',
|
|
133
|
+
transport: 'http',
|
|
134
|
+
url: 'http://localhost:3000/mcp',
|
|
135
|
+
})).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns null for valid sse config', () => {
|
|
139
|
+
expect(SkillManager.validateConfig({
|
|
140
|
+
name: 'test',
|
|
141
|
+
transport: 'sse',
|
|
142
|
+
url: 'http://localhost:3000/sse',
|
|
143
|
+
})).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns error for missing name', () => {
|
|
147
|
+
expect(SkillManager.validateConfig({
|
|
148
|
+
name: '',
|
|
149
|
+
transport: 'stdio',
|
|
150
|
+
command: 'node',
|
|
151
|
+
})).toContain('name');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns error for invalid transport', () => {
|
|
155
|
+
expect(SkillManager.validateConfig({
|
|
156
|
+
name: 'test',
|
|
157
|
+
transport: 'invalid' as any,
|
|
158
|
+
})).toContain('transport');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns error for stdio without command', () => {
|
|
162
|
+
expect(SkillManager.validateConfig({
|
|
163
|
+
name: 'test',
|
|
164
|
+
transport: 'stdio',
|
|
165
|
+
})).toContain('command');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns error for http without url', () => {
|
|
169
|
+
expect(SkillManager.validateConfig({
|
|
170
|
+
name: 'test',
|
|
171
|
+
transport: 'http',
|
|
172
|
+
})).toContain('url');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns error for sse without url', () => {
|
|
176
|
+
expect(SkillManager.validateConfig({
|
|
177
|
+
name: 'test',
|
|
178
|
+
transport: 'sse',
|
|
179
|
+
})).toContain('url');
|
|
180
|
+
});
|
|
181
|
+
});
|