@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.
@@ -0,0 +1,98 @@
1
+ import type { Skill } from '@operor/core';
2
+ import { MCPSkill } from './MCPSkill.js';
3
+ import { PromptSkill } from './PromptSkill.js';
4
+ import type { MCPSkillConfig, SkillsConfig, PromptSkillConfig } from './config.js';
5
+ import { isPromptSkillConfig } from './config.js';
6
+
7
+ export class SkillManager {
8
+ private skills: Skill[] = [];
9
+
10
+ async initialize(config: SkillsConfig): Promise<Skill[]> {
11
+ const enabledSkills = config.skills.filter(s => s.enabled !== false);
12
+
13
+ for (const skillConfig of enabledSkills) {
14
+ if (isPromptSkillConfig(skillConfig)) {
15
+ const validationError = SkillManager.validatePromptConfig(skillConfig);
16
+ if (validationError) {
17
+ console.warn(`[SkillManager] Skipping prompt skill "${skillConfig.name}": ${validationError}`);
18
+ continue;
19
+ }
20
+ try {
21
+ const skill = new PromptSkill(skillConfig);
22
+ await skill.initialize();
23
+ this.skills.push(skill);
24
+ console.log(`[SkillManager] ✅ ${skill.name}: prompt skill loaded`);
25
+ } catch (error: any) {
26
+ console.warn(`[SkillManager] ⚠️ Failed to load prompt skill "${skillConfig.name}": ${error.message}`);
27
+ }
28
+ } else {
29
+ const validationError = SkillManager.validateConfig(skillConfig);
30
+ if (validationError) {
31
+ console.warn(`[SkillManager] Skipping skill "${skillConfig.name}": ${validationError}`);
32
+ continue;
33
+ }
34
+ try {
35
+ const skill = new MCPSkill(skillConfig);
36
+ await skill.initialize();
37
+ this.skills.push(skill);
38
+ const toolCount = Object.keys(skill.tools).length;
39
+ console.log(`[SkillManager] ✅ ${skill.name}: ${toolCount} tools loaded`);
40
+ } catch (error: any) {
41
+ console.warn(`[SkillManager] ⚠️ Failed to start skill "${skillConfig.name}": ${error.message}`);
42
+ }
43
+ }
44
+ }
45
+
46
+ return this.skills;
47
+ }
48
+
49
+ async closeAll(): Promise<void> {
50
+ const closePromises = this.skills.map(async (skill) => {
51
+ try {
52
+ if (skill.close) await skill.close();
53
+ } catch (error: any) {
54
+ console.warn(`[SkillManager] Error closing skill "${skill.name}": ${error.message}`);
55
+ }
56
+ });
57
+ await Promise.all(closePromises);
58
+ this.skills = [];
59
+ }
60
+
61
+ getSkills(): Skill[] {
62
+ return this.skills;
63
+ }
64
+
65
+ getPromptSkills(): PromptSkill[] {
66
+ return this.skills.filter((s): s is PromptSkill => s instanceof PromptSkill);
67
+ }
68
+
69
+ getMCPSkills(): MCPSkill[] {
70
+ return this.skills.filter((s): s is MCPSkill => s instanceof MCPSkill);
71
+ }
72
+
73
+ static validateConfig(config: MCPSkillConfig): string | null {
74
+ if (!config.name || typeof config.name !== 'string') {
75
+ return 'Missing or invalid "name" field';
76
+ }
77
+ if (!['stdio', 'http', 'sse'].includes(config.transport)) {
78
+ return `Invalid transport "${config.transport}" — must be "stdio", "http", or "sse"`;
79
+ }
80
+ if (config.transport === 'stdio' && !config.command) {
81
+ return 'stdio transport requires a "command" field';
82
+ }
83
+ if ((config.transport === 'http' || config.transport === 'sse') && !config.url) {
84
+ return `${config.transport} transport requires a "url" field`;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ static validatePromptConfig(config: PromptSkillConfig): string | null {
90
+ if (!config.name || typeof config.name !== 'string') {
91
+ return 'Missing or invalid "name" field';
92
+ }
93
+ if (!config.content || typeof config.content !== 'string' || !config.content.trim()) {
94
+ return 'Missing or empty "content" field';
95
+ }
96
+ return null;
97
+ }
98
+ }
@@ -0,0 +1,308 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { MCPSkill } from '../MCPSkill.js';
3
+ import type { MCPSkillConfig } from '../config.js';
4
+
5
+ // Mock @ai-sdk/mcp
6
+ vi.mock('@ai-sdk/mcp', () => ({
7
+ createMCPClient: vi.fn(),
8
+ }));
9
+
10
+ // Mock @modelcontextprotocol/sdk stdio transport
11
+ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => {
12
+ return {
13
+ StdioClientTransport: class StdioClientTransport {
14
+ command: string;
15
+ args: string[];
16
+ env: any;
17
+ constructor(opts: any) {
18
+ this.command = opts.command;
19
+ this.args = opts.args;
20
+ this.env = opts.env;
21
+ }
22
+ },
23
+ };
24
+ });
25
+
26
+ import { createMCPClient } from '@ai-sdk/mcp';
27
+
28
+ const mockCreateMCPClient = vi.mocked(createMCPClient);
29
+
30
+ function makeMockClient(tools: Record<string, any> = {}) {
31
+ return {
32
+ tools: vi.fn().mockResolvedValue(tools),
33
+ close: vi.fn().mockResolvedValue(undefined),
34
+ };
35
+ }
36
+
37
+ describe('MCPSkill', () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ it('sets name from config', () => {
43
+ const skill = new MCPSkill({
44
+ name: 'github',
45
+ transport: 'http',
46
+ url: 'http://localhost:3000/mcp',
47
+ });
48
+ expect(skill.name).toBe('github');
49
+ });
50
+
51
+ it('is not ready before initialize', () => {
52
+ const skill = new MCPSkill({
53
+ name: 'test',
54
+ transport: 'http',
55
+ url: 'http://localhost:3000/mcp',
56
+ });
57
+ expect(skill.isReady()).toBe(false);
58
+ expect(skill.tools).toEqual({});
59
+ });
60
+
61
+ describe('initialize', () => {
62
+ it('connects and discovers tools via http transport', async () => {
63
+ const mockTools = {
64
+ search: {
65
+ description: 'Search the web',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: { query: { type: 'string' } },
69
+ },
70
+ execute: vi.fn().mockResolvedValue('result'),
71
+ },
72
+ };
73
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
74
+
75
+ const skill = new MCPSkill({
76
+ name: 'brave',
77
+ transport: 'http',
78
+ url: 'http://localhost:3000/mcp',
79
+ });
80
+ await skill.initialize();
81
+
82
+ expect(skill.isReady()).toBe(true);
83
+ expect(Object.keys(skill.tools)).toEqual(['brave__search']);
84
+ expect(skill.tools['brave__search'].description).toBe('Search the web');
85
+ });
86
+
87
+ it('uses custom toolPrefix when set', async () => {
88
+ const mockTools = {
89
+ list_repos: {
90
+ description: 'List repos',
91
+ parameters: { type: 'object', properties: {} },
92
+ execute: vi.fn(),
93
+ },
94
+ };
95
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
96
+
97
+ const skill = new MCPSkill({
98
+ name: 'github',
99
+ transport: 'http',
100
+ url: 'http://localhost:3000/mcp',
101
+ toolPrefix: 'gh',
102
+ });
103
+ await skill.initialize();
104
+
105
+ expect(Object.keys(skill.tools)).toEqual(['gh__list_repos']);
106
+ });
107
+
108
+ it('handles tools with jsonSchema wrapper', async () => {
109
+ const innerSchema = {
110
+ type: 'object',
111
+ properties: { id: { type: 'number' } },
112
+ required: ['id'],
113
+ };
114
+ const mockTools = {
115
+ get_order: {
116
+ description: 'Get order by ID',
117
+ parameters: { jsonSchema: innerSchema },
118
+ execute: vi.fn(),
119
+ },
120
+ };
121
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
122
+
123
+ const skill = new MCPSkill({
124
+ name: 'shop',
125
+ transport: 'http',
126
+ url: 'http://localhost:3000/mcp',
127
+ });
128
+ await skill.initialize();
129
+
130
+ expect(skill.tools['shop__get_order'].parameters).toEqual(innerSchema);
131
+ });
132
+
133
+ it('handles tools with inputSchema (AI SDK MCP client format)', async () => {
134
+ const innerSchema = {
135
+ type: 'object',
136
+ properties: { url: { type: 'string', description: 'URL to crawl' } },
137
+ required: ['url'],
138
+ additionalProperties: false,
139
+ };
140
+ const mockTools = {
141
+ md: {
142
+ description: 'Crawl a URL and return markdown',
143
+ inputSchema: { jsonSchema: innerSchema },
144
+ execute: vi.fn(),
145
+ },
146
+ };
147
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
148
+
149
+ const skill = new MCPSkill({
150
+ name: 'crawl4ai',
151
+ transport: 'sse',
152
+ url: 'http://localhost:11235/mcp/sse',
153
+ });
154
+ await skill.initialize();
155
+
156
+ expect(skill.tools['crawl4ai__md'].parameters).toEqual(innerSchema);
157
+ });
158
+
159
+ it('falls back to empty schema when no parameters', async () => {
160
+ const mockTools = {
161
+ ping: {
162
+ description: 'Ping',
163
+ execute: vi.fn(),
164
+ },
165
+ };
166
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
167
+
168
+ const skill = new MCPSkill({
169
+ name: 'svc',
170
+ transport: 'http',
171
+ url: 'http://localhost:3000/mcp',
172
+ });
173
+ await skill.initialize();
174
+
175
+ expect(skill.tools['svc__ping'].parameters).toEqual({
176
+ type: 'object',
177
+ properties: {},
178
+ });
179
+ });
180
+
181
+ it('executes a tool by delegating to the MCP tool', async () => {
182
+ const mockExecute = vi.fn().mockResolvedValue('tool-result');
183
+ const mockTools = {
184
+ do_thing: {
185
+ description: 'Do thing',
186
+ parameters: { type: 'object', properties: {} },
187
+ execute: mockExecute,
188
+ },
189
+ };
190
+ mockCreateMCPClient.mockResolvedValue(makeMockClient(mockTools) as any);
191
+
192
+ const skill = new MCPSkill({
193
+ name: 'test',
194
+ transport: 'http',
195
+ url: 'http://localhost:3000/mcp',
196
+ });
197
+ await skill.initialize();
198
+
199
+ const result = await skill.tools['test__do_thing'].execute!({ foo: 'bar' });
200
+ expect(result).toBe('tool-result');
201
+ expect(mockExecute).toHaveBeenCalledWith(
202
+ { foo: 'bar' },
203
+ expect.objectContaining({ toolCallId: expect.stringMatching(/^call_/) }),
204
+ );
205
+ });
206
+
207
+ it('creates stdio transport for stdio config', async () => {
208
+ const mockClient = makeMockClient({});
209
+ mockCreateMCPClient.mockResolvedValue(mockClient as any);
210
+
211
+ const skill = new MCPSkill({
212
+ name: 'fs',
213
+ transport: 'stdio',
214
+ command: 'node',
215
+ args: ['server.js'],
216
+ });
217
+ await skill.initialize();
218
+
219
+ expect(mockCreateMCPClient).toHaveBeenCalledWith({
220
+ transport: expect.objectContaining({ command: 'node' }),
221
+ });
222
+ });
223
+
224
+ it('creates sse transport for sse config', async () => {
225
+ const mockClient = makeMockClient({});
226
+ mockCreateMCPClient.mockResolvedValue(mockClient as any);
227
+
228
+ const skill = new MCPSkill({
229
+ name: 'remote',
230
+ transport: 'sse',
231
+ url: 'http://localhost:3000/sse',
232
+ });
233
+ await skill.initialize();
234
+
235
+ expect(mockCreateMCPClient).toHaveBeenCalledWith({
236
+ transport: { type: 'sse', url: 'http://localhost:3000/sse', headers: undefined },
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('close', () => {
242
+ it('closes the client and resets ready state', async () => {
243
+ const mockClient = makeMockClient({});
244
+ mockCreateMCPClient.mockResolvedValue(mockClient as any);
245
+
246
+ const skill = new MCPSkill({
247
+ name: 'test',
248
+ transport: 'http',
249
+ url: 'http://localhost:3000/mcp',
250
+ });
251
+ await skill.initialize();
252
+ expect(skill.isReady()).toBe(true);
253
+
254
+ await skill.close();
255
+ expect(skill.isReady()).toBe(false);
256
+ expect(mockClient.close).toHaveBeenCalled();
257
+ });
258
+
259
+ it('handles close when not initialized', async () => {
260
+ const skill = new MCPSkill({
261
+ name: 'test',
262
+ transport: 'http',
263
+ url: 'http://localhost:3000/mcp',
264
+ });
265
+ // Should not throw
266
+ await skill.close();
267
+ expect(skill.isReady()).toBe(false);
268
+ });
269
+ });
270
+
271
+ describe('transport validation', () => {
272
+ it('throws for stdio without command', async () => {
273
+ const skill = new MCPSkill({
274
+ name: 'bad',
275
+ transport: 'stdio',
276
+ } as MCPSkillConfig);
277
+
278
+ await expect(skill.initialize()).rejects.toThrow('stdio transport requires a "command" field');
279
+ });
280
+
281
+ it('throws for http without url', async () => {
282
+ const skill = new MCPSkill({
283
+ name: 'bad',
284
+ transport: 'http',
285
+ } as MCPSkillConfig);
286
+
287
+ await expect(skill.initialize()).rejects.toThrow('http transport requires a "url" field');
288
+ });
289
+
290
+ it('throws for sse without url', async () => {
291
+ const skill = new MCPSkill({
292
+ name: 'bad',
293
+ transport: 'sse',
294
+ } as MCPSkillConfig);
295
+
296
+ await expect(skill.initialize()).rejects.toThrow('sse transport requires a "url" field');
297
+ });
298
+
299
+ it('throws for unknown transport type', async () => {
300
+ const skill = new MCPSkill({
301
+ name: 'bad',
302
+ transport: 'websocket' as any,
303
+ });
304
+
305
+ await expect(skill.initialize()).rejects.toThrow('Unknown transport type: websocket');
306
+ });
307
+ });
308
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SkillManager } from '../SkillManager.js';
3
+ import type { SkillsConfig, MCPSkillConfig } from '../config.js';
4
+
5
+ // Mock MCPSkill so we don't need real MCP connections
6
+ vi.mock('../MCPSkill.js', () => {
7
+ return {
8
+ MCPSkill: class MockMCPSkill {
9
+ name: string;
10
+ tools: Record<string, any> = {};
11
+ private _ready = false;
12
+ private _shouldFail: boolean;
13
+
14
+ constructor(config: MCPSkillConfig) {
15
+ this.name = config.name;
16
+ // Convention: name starting with "fail-" will throw on initialize
17
+ this._shouldFail = config.name.startsWith('fail-');
18
+ }
19
+
20
+ async initialize(): Promise<void> {
21
+ if (this._shouldFail) {
22
+ throw new Error(`Connection refused for ${this.name}`);
23
+ }
24
+ this._ready = true;
25
+ this.tools = {
26
+ [`${this.name}__tool1`]: {
27
+ name: `${this.name}__tool1`,
28
+ description: 'A test tool',
29
+ parameters: {},
30
+ },
31
+ };
32
+ }
33
+
34
+ isReady(): boolean {
35
+ return this._ready;
36
+ }
37
+
38
+ async close(): Promise<void> {
39
+ this._ready = false;
40
+ }
41
+ },
42
+ };
43
+ });
44
+
45
+ describe('SkillManager', () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ describe('initialize', () => {
51
+ it('initializes enabled skills', async () => {
52
+ const manager = new SkillManager();
53
+ const config: SkillsConfig = {
54
+ skills: [
55
+ { name: 'alpha', transport: 'http', url: 'http://localhost:3000/mcp' },
56
+ { name: 'beta', transport: 'http', url: 'http://localhost:3001/mcp' },
57
+ ],
58
+ };
59
+
60
+ const skills = await manager.initialize(config);
61
+ expect(skills).toHaveLength(2);
62
+ expect(skills[0].name).toBe('alpha');
63
+ expect(skills[1].name).toBe('beta');
64
+ });
65
+
66
+ it('skips disabled skills', async () => {
67
+ const manager = new SkillManager();
68
+ const config: SkillsConfig = {
69
+ skills: [
70
+ { name: 'enabled-one', transport: 'http', url: 'http://localhost/mcp' },
71
+ { name: 'disabled-one', transport: 'http', url: 'http://localhost/mcp', enabled: false },
72
+ ],
73
+ };
74
+
75
+ const skills = await manager.initialize(config);
76
+ expect(skills).toHaveLength(1);
77
+ expect(skills[0].name).toBe('enabled-one');
78
+ });
79
+
80
+ it('skips skills with invalid config', async () => {
81
+ const manager = new SkillManager();
82
+ const config: SkillsConfig = {
83
+ skills: [
84
+ { name: '', transport: 'stdio', command: 'node' }, // invalid: empty name
85
+ { name: 'valid', transport: 'http', url: 'http://localhost/mcp' },
86
+ ],
87
+ };
88
+
89
+ const skills = await manager.initialize(config);
90
+ expect(skills).toHaveLength(1);
91
+ expect(skills[0].name).toBe('valid');
92
+ });
93
+
94
+ it('skips skills that fail to start and continues', async () => {
95
+ const manager = new SkillManager();
96
+ const config: SkillsConfig = {
97
+ skills: [
98
+ { name: 'fail-broken', transport: 'http', url: 'http://localhost/mcp' },
99
+ { name: 'healthy', transport: 'http', url: 'http://localhost/mcp' },
100
+ ],
101
+ };
102
+
103
+ const skills = await manager.initialize(config);
104
+ expect(skills).toHaveLength(1);
105
+ expect(skills[0].name).toBe('healthy');
106
+ });
107
+
108
+ it('returns empty array when all skills fail', async () => {
109
+ const manager = new SkillManager();
110
+ const config: SkillsConfig = {
111
+ skills: [
112
+ { name: 'fail-one', transport: 'http', url: 'http://localhost/mcp' },
113
+ { name: 'fail-two', transport: 'http', url: 'http://localhost/mcp' },
114
+ ],
115
+ };
116
+
117
+ const skills = await manager.initialize(config);
118
+ expect(skills).toHaveLength(0);
119
+ });
120
+
121
+ it('returns empty array for empty config', async () => {
122
+ const manager = new SkillManager();
123
+ const skills = await manager.initialize({ skills: [] });
124
+ expect(skills).toHaveLength(0);
125
+ });
126
+ });
127
+
128
+ describe('getSkills', () => {
129
+ it('returns initialized skills', async () => {
130
+ const manager = new SkillManager();
131
+ await manager.initialize({
132
+ skills: [
133
+ { name: 'one', transport: 'http', url: 'http://localhost/mcp' },
134
+ { name: 'two', transport: 'http', url: 'http://localhost/mcp' },
135
+ ],
136
+ });
137
+
138
+ const skills = manager.getSkills();
139
+ expect(skills).toHaveLength(2);
140
+ expect(skills.map(s => s.name)).toEqual(['one', 'two']);
141
+ });
142
+
143
+ it('returns empty array before initialization', () => {
144
+ const manager = new SkillManager();
145
+ expect(manager.getSkills()).toEqual([]);
146
+ });
147
+ });
148
+
149
+ describe('closeAll', () => {
150
+ it('closes all skills and clears the list', async () => {
151
+ const manager = new SkillManager();
152
+ await manager.initialize({
153
+ skills: [
154
+ { name: 'a', transport: 'http', url: 'http://localhost/mcp' },
155
+ { name: 'b', transport: 'http', url: 'http://localhost/mcp' },
156
+ ],
157
+ });
158
+
159
+ expect(manager.getSkills()).toHaveLength(2);
160
+
161
+ await manager.closeAll();
162
+ expect(manager.getSkills()).toHaveLength(0);
163
+ });
164
+
165
+ it('handles closeAll when no skills are initialized', async () => {
166
+ const manager = new SkillManager();
167
+ // Should not throw
168
+ await manager.closeAll();
169
+ expect(manager.getSkills()).toEqual([]);
170
+ });
171
+ });
172
+ });