wxo-builder-mcp-server 1.0.8
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/CHANGELOG.md +66 -0
- package/LICENSE +194 -0
- package/README.md +415 -0
- package/dist/agents.js +448 -0
- package/dist/auth.js +70 -0
- package/dist/config.js +39 -0
- package/dist/connections.js +191 -0
- package/dist/flows.js +73 -0
- package/dist/index.js +466 -0
- package/dist/models.js +33 -0
- package/dist/skills.js +789 -0
- package/package.json +99 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +18 -0
- package/watson-orchestrate-openapi.json +359 -0
package/dist/skills.js
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WxO MCP Server - Skills/Tools API
|
|
3
|
+
* Mirrors vscode-extension/api/skills.ts (buildToolSpec, connection_id, skill_v2.json)
|
|
4
|
+
*
|
|
5
|
+
* @author Markus van Kempen
|
|
6
|
+
* @license Apache-2.0
|
|
7
|
+
*/
|
|
8
|
+
import { woFetch } from './auth.js';
|
|
9
|
+
import archiver from 'archiver';
|
|
10
|
+
import fetch from 'node-fetch';
|
|
11
|
+
import { ensureTestAgentForTool } from './agents.js';
|
|
12
|
+
import { createConnection, createConnectionConfiguration, setApiKeyCredentials, getConnection } from './connections.js';
|
|
13
|
+
/** Convert WxO skill format to OAS (mirrors extension skillToOas for copy flow). */
|
|
14
|
+
export function skillToOas(skill) {
|
|
15
|
+
const b = skill?.binding?.openapi;
|
|
16
|
+
const method = (b?.http_method || 'GET').toLowerCase();
|
|
17
|
+
const path = b?.http_path || '/';
|
|
18
|
+
const servers = (b?.servers || []).map((s) => (typeof s === 'string' ? s : s?.url)).filter(Boolean);
|
|
19
|
+
const security = b?.security;
|
|
20
|
+
const connectionId = b?.connection_id;
|
|
21
|
+
const inputSchema = skill?.input_schema;
|
|
22
|
+
const outputSchema = skill?.output_schema;
|
|
23
|
+
const params = [];
|
|
24
|
+
if (inputSchema?.properties) {
|
|
25
|
+
for (const [key, prop] of Object.entries(inputSchema.properties)) {
|
|
26
|
+
const name = prop.aliasName ?? key;
|
|
27
|
+
params.push({
|
|
28
|
+
name,
|
|
29
|
+
in: prop.in || 'query',
|
|
30
|
+
required: (inputSchema.required || []).includes(key),
|
|
31
|
+
description: prop.description || '',
|
|
32
|
+
schema: {
|
|
33
|
+
type: prop.type || 'string',
|
|
34
|
+
title: prop.title,
|
|
35
|
+
default: prop.default,
|
|
36
|
+
...(prop.enum ? { enum: prop.enum } : {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const title = (skill?.display_name || skill?.name || 'Tool') + ' (Copy)';
|
|
42
|
+
const baseId = (skill?.name || 'tool').replace(/[^a-zA-Z0-9_]/g, '_');
|
|
43
|
+
const skillId = `${baseId}_copy_v1`;
|
|
44
|
+
const oas = {
|
|
45
|
+
openapi: '3.0.1',
|
|
46
|
+
info: {
|
|
47
|
+
title,
|
|
48
|
+
version: skill?.info?.version || '1.0.0',
|
|
49
|
+
description: skill?.description || '',
|
|
50
|
+
'x-ibm-skill-name': title,
|
|
51
|
+
'x-ibm-skill-id': skillId,
|
|
52
|
+
},
|
|
53
|
+
servers: servers.length ? servers.map((u) => ({ url: u })) : [{ url: 'https://httpbin.org' }],
|
|
54
|
+
paths: {
|
|
55
|
+
[path]: {
|
|
56
|
+
[method]: {
|
|
57
|
+
operationId: skill?.name || 'operation',
|
|
58
|
+
summary: skill?.display_name || skill?.name || 'Operation',
|
|
59
|
+
parameters: params,
|
|
60
|
+
responses: {
|
|
61
|
+
'200': {
|
|
62
|
+
description: 'Success',
|
|
63
|
+
content: {
|
|
64
|
+
'application/json': {
|
|
65
|
+
schema: outputSchema || { type: 'object' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
if (security?.length)
|
|
75
|
+
oas['x-ibm-security'] = security;
|
|
76
|
+
if (connectionId)
|
|
77
|
+
oas['x-ibm-connection-id'] = connectionId;
|
|
78
|
+
return oas;
|
|
79
|
+
}
|
|
80
|
+
/** Derive WxO binding security from OpenAPI spec. */
|
|
81
|
+
function deriveBindingSecurity(openApiSpec, op) {
|
|
82
|
+
const explicit = openApiSpec['x-ibm-security'] ?? openApiSpec.binding?.openapi?.security;
|
|
83
|
+
if (Array.isArray(explicit) && explicit.length > 0)
|
|
84
|
+
return explicit;
|
|
85
|
+
const schemes = openApiSpec.components?.securitySchemes ?? {};
|
|
86
|
+
const secRefs = op?.security ?? openApiSpec.security ?? [];
|
|
87
|
+
if (!Array.isArray(secRefs) || secRefs.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
const flat = [];
|
|
90
|
+
for (const ref of secRefs) {
|
|
91
|
+
if (typeof ref !== 'object' || !ref)
|
|
92
|
+
continue;
|
|
93
|
+
const name = Object.keys(ref)[0];
|
|
94
|
+
const scheme = schemes[name];
|
|
95
|
+
if (scheme?.type === 'apiKey') {
|
|
96
|
+
flat.push({
|
|
97
|
+
type: 'apiKey',
|
|
98
|
+
in: scheme.in || 'query',
|
|
99
|
+
name: scheme.name || 'apiKey',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else if (scheme?.type === 'http' || scheme?.scheme === 'bearer') {
|
|
103
|
+
flat.push({
|
|
104
|
+
type: 'http',
|
|
105
|
+
scheme: scheme.scheme || 'bearer',
|
|
106
|
+
name: scheme.name || 'Authorization',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return flat;
|
|
111
|
+
}
|
|
112
|
+
/** Build full tool spec from toolSpec + OpenAPI (matches extension buildToolSpec). */
|
|
113
|
+
function buildToolSpec(toolSpec, openApiSpec) {
|
|
114
|
+
const spec = {
|
|
115
|
+
name: toolSpec.name,
|
|
116
|
+
display_name: openApiSpec.info?.['x-ibm-skill-name'] || openApiSpec.info?.title || toolSpec.name,
|
|
117
|
+
description: toolSpec.description,
|
|
118
|
+
permission: toolSpec.permission || 'read_write',
|
|
119
|
+
restrictions: toolSpec.restrictions || undefined,
|
|
120
|
+
tags: toolSpec.tags || undefined,
|
|
121
|
+
};
|
|
122
|
+
if (openApiSpec.paths) {
|
|
123
|
+
const pathKeys = Object.keys(openApiSpec.paths);
|
|
124
|
+
if (pathKeys.length > 0) {
|
|
125
|
+
const pathKey = pathKeys[0];
|
|
126
|
+
const pathObj = openApiSpec.paths[pathKey];
|
|
127
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete'];
|
|
128
|
+
for (const method of methods) {
|
|
129
|
+
if (!pathObj[method])
|
|
130
|
+
continue;
|
|
131
|
+
const op = pathObj[method];
|
|
132
|
+
const servers = (openApiSpec.servers || []).map((s) => (typeof s === 'string' ? s : s.url));
|
|
133
|
+
const connectionId = openApiSpec['x-ibm-connection-id'] ?? openApiSpec.binding?.openapi?.connection_id ?? null;
|
|
134
|
+
const security = deriveBindingSecurity(openApiSpec, op);
|
|
135
|
+
spec.binding = {
|
|
136
|
+
openapi: {
|
|
137
|
+
http_method: method.toUpperCase(),
|
|
138
|
+
http_path: pathKey,
|
|
139
|
+
security: security,
|
|
140
|
+
servers: servers,
|
|
141
|
+
connection_id: connectionId || null,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const properties = {};
|
|
145
|
+
const required = [];
|
|
146
|
+
const usedKeys = new Set();
|
|
147
|
+
if (op.parameters && op.parameters.length > 0) {
|
|
148
|
+
op.parameters.forEach((p) => {
|
|
149
|
+
const paramIn = p.in || 'query';
|
|
150
|
+
const paramName = p.name;
|
|
151
|
+
let propKey = paramName;
|
|
152
|
+
if (usedKeys.has(propKey)) {
|
|
153
|
+
propKey = `${paramIn}_${paramName}`;
|
|
154
|
+
}
|
|
155
|
+
usedKeys.add(propKey);
|
|
156
|
+
const propSchema = {
|
|
157
|
+
type: p.schema?.type || 'string',
|
|
158
|
+
title: p.schema?.title ?? paramName,
|
|
159
|
+
description: p.description || p.schema?.description || '',
|
|
160
|
+
in: paramIn,
|
|
161
|
+
};
|
|
162
|
+
if (p.schema?.default !== undefined && p.schema?.default !== null) {
|
|
163
|
+
propSchema.default = p.schema.default;
|
|
164
|
+
}
|
|
165
|
+
if (propKey !== paramName) {
|
|
166
|
+
propSchema.aliasName = paramName;
|
|
167
|
+
}
|
|
168
|
+
properties[propKey] = propSchema;
|
|
169
|
+
if (p.required) {
|
|
170
|
+
required.push(propKey);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
spec.input_schema = {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties,
|
|
177
|
+
required: required.length > 0 ? required : undefined,
|
|
178
|
+
};
|
|
179
|
+
if (op.responses?.['200']?.content?.['application/json']?.schema) {
|
|
180
|
+
const responseSchema = op.responses['200'].content['application/json'].schema;
|
|
181
|
+
spec.output_schema = {
|
|
182
|
+
...responseSchema,
|
|
183
|
+
description: responseSchema.description || op.responses['200'].description || 'Success',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return spec;
|
|
191
|
+
}
|
|
192
|
+
function createOpenApiZip(openApiSpec) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
195
|
+
const chunks = [];
|
|
196
|
+
archive.on('data', (chunk) => chunks.push(chunk));
|
|
197
|
+
archive.on('error', (err) => reject(err));
|
|
198
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
199
|
+
archive.append(JSON.stringify(openApiSpec, null, 2), { name: 'skill_v2.json' });
|
|
200
|
+
archive.append('2.0.0\n', { name: 'bundle-format' });
|
|
201
|
+
archive.finalize();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Sanitize tool name for Watson Orchestrate: letters, digits, underscores only; cannot start with digit. */
|
|
205
|
+
function sanitizeToolName(name) {
|
|
206
|
+
let s = name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/_+/g, '_');
|
|
207
|
+
if (/^[0-9]/.test(s))
|
|
208
|
+
s = 't_' + s;
|
|
209
|
+
return s || 'tool_copy';
|
|
210
|
+
}
|
|
211
|
+
/** Returns true if a tool has a connection/security binding (matches extension skillsView). */
|
|
212
|
+
function hasConnection(skill) {
|
|
213
|
+
const security = skill?.binding?.openapi?.security ??
|
|
214
|
+
skill?.binding?.security ??
|
|
215
|
+
skill?.security;
|
|
216
|
+
const connectionId = skill?.binding?.openapi?.connection_id ??
|
|
217
|
+
skill?.binding?.connection_id ??
|
|
218
|
+
skill?.connection_id;
|
|
219
|
+
return (Array.isArray(security) && security.length > 0) || !!connectionId;
|
|
220
|
+
}
|
|
221
|
+
/** Extract skills array from API response (matches extension skillsView normalization). */
|
|
222
|
+
function extractSkills(data) {
|
|
223
|
+
if (Array.isArray(data))
|
|
224
|
+
return data;
|
|
225
|
+
if (data?.items && Array.isArray(data.items))
|
|
226
|
+
return data.items;
|
|
227
|
+
if (data?.tools && Array.isArray(data.tools))
|
|
228
|
+
return data.tools;
|
|
229
|
+
if (data?.data && Array.isArray(data.data))
|
|
230
|
+
return data.data;
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* List tools grouped like the extension Tools view: Tools with Connections + Standard Tools.
|
|
235
|
+
* Use this for prompts like "List my Watson Orchestrate tools with active connections".
|
|
236
|
+
*/
|
|
237
|
+
export async function listToolsWithConnections(limit = 100) {
|
|
238
|
+
const raw = await listSkills(limit, 0);
|
|
239
|
+
const skills = extractSkills(raw);
|
|
240
|
+
const withConnection = skills.filter((s) => hasConnection(s));
|
|
241
|
+
const standard = skills.filter((s) => !hasConnection(s));
|
|
242
|
+
return {
|
|
243
|
+
summary: {
|
|
244
|
+
total: skills.length,
|
|
245
|
+
tools_with_connections: withConnection.length,
|
|
246
|
+
standard_tools: standard.length,
|
|
247
|
+
},
|
|
248
|
+
tools_with_connections: withConnection.map((s) => ({
|
|
249
|
+
id: s.id || s.name,
|
|
250
|
+
display_name: s.display_name || s.name || s.id || 'Unnamed',
|
|
251
|
+
description: s.description || '',
|
|
252
|
+
connection_id: s?.binding?.openapi?.connection_id || null,
|
|
253
|
+
auth_type: s?.binding?.openapi?.security?.[0]?.type || 'apiKey',
|
|
254
|
+
})),
|
|
255
|
+
standard_tools: standard.map((s) => ({
|
|
256
|
+
id: s.id || s.name,
|
|
257
|
+
display_name: s.display_name || s.name || s.id || 'Unnamed',
|
|
258
|
+
description: s.description || '',
|
|
259
|
+
})),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* List only standard tools (tools with no connections). Returns accurate count and list from Watson Orchestrate.
|
|
264
|
+
* Use for "list standard tools" or "tools with no connections" – tools with empty/missing security and connection_id.
|
|
265
|
+
*/
|
|
266
|
+
export async function listStandardTools(limit = 100) {
|
|
267
|
+
const raw = await listToolsWithConnections(limit);
|
|
268
|
+
const standard = raw.standard_tools || [];
|
|
269
|
+
return {
|
|
270
|
+
count: standard.length,
|
|
271
|
+
standard_tools: standard.map((s) => ({
|
|
272
|
+
id: s.id || s.name || 'unknown',
|
|
273
|
+
display_name: s.display_name || s.name || s.id || 'Unnamed',
|
|
274
|
+
description: s.description || '',
|
|
275
|
+
})),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve tool name (e.g. "News Search Tool") to tool ID by listing tools and matching display_name/name.
|
|
280
|
+
*/
|
|
281
|
+
export async function resolveToolByName(toolName) {
|
|
282
|
+
const raw = await listSkills(100, 0);
|
|
283
|
+
const skills = extractSkills(raw);
|
|
284
|
+
const nameLower = toolName.toLowerCase().trim();
|
|
285
|
+
const match = skills.find((s) => (s.display_name || '').toLowerCase() === nameLower ||
|
|
286
|
+
(s.name || '').toLowerCase() === nameLower ||
|
|
287
|
+
(s.display_name || '').toLowerCase().includes(nameLower) ||
|
|
288
|
+
(s.name || '').toLowerCase().includes(nameLower));
|
|
289
|
+
return match ? match.id || match.name : null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Invoke a tool via Watson Orchestrate agentic runs API.
|
|
293
|
+
* Uses an agent that has the tool in its toolkit (creates WxoBuilderTestAgent if needed).
|
|
294
|
+
*/
|
|
295
|
+
async function invokeToolRemote(toolId, parameters = {}, agentId) {
|
|
296
|
+
const directive = Object.keys(parameters).length > 0
|
|
297
|
+
? `Execute the tool with these parameters. Return the raw result data.\n\nParameters: ${JSON.stringify(parameters)}`
|
|
298
|
+
: 'Execute the tool with default parameters. Return the raw result data.';
|
|
299
|
+
const payload = {
|
|
300
|
+
tool_id: toolId,
|
|
301
|
+
parameters,
|
|
302
|
+
message: { role: 'user', content: directive },
|
|
303
|
+
};
|
|
304
|
+
if (agentId)
|
|
305
|
+
payload.agent_id = agentId;
|
|
306
|
+
const runRes = await woFetch('/v1/orchestrate/runs', {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
body: JSON.stringify(payload),
|
|
309
|
+
});
|
|
310
|
+
if (!runRes.ok) {
|
|
311
|
+
const errText = await runRes.text();
|
|
312
|
+
throw new Error(`Run failed (${runRes.status}): ${errText}`);
|
|
313
|
+
}
|
|
314
|
+
const runData = (await runRes.json());
|
|
315
|
+
const threadId = runData.thread_id;
|
|
316
|
+
if (!threadId) {
|
|
317
|
+
throw new Error('No thread_id returned from run. Response: ' + JSON.stringify(runData));
|
|
318
|
+
}
|
|
319
|
+
const maxAttempts = 12;
|
|
320
|
+
let messages = [];
|
|
321
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
322
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
323
|
+
const msgRes = await woFetch(`/v1/orchestrate/threads/${threadId}/messages`);
|
|
324
|
+
if (msgRes.ok) {
|
|
325
|
+
const msgData = (await msgRes.json());
|
|
326
|
+
messages = Array.isArray(msgData) ? msgData : msgData.messages || msgData.data || [];
|
|
327
|
+
const assistantMsg = messages.find((m) => m.role === 'assistant');
|
|
328
|
+
if (assistantMsg)
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const assistantMsg = messages.find((m) => m.role === 'assistant') || messages[0];
|
|
333
|
+
let responseData = null;
|
|
334
|
+
if (assistantMsg && assistantMsg.content) {
|
|
335
|
+
const content = assistantMsg.content;
|
|
336
|
+
if (Array.isArray(content)) {
|
|
337
|
+
const toolResult = content.find((c) => c.type === 'tool_result');
|
|
338
|
+
const textBlock = content.find((c) => c.type === 'text');
|
|
339
|
+
if (toolResult != null) {
|
|
340
|
+
responseData = toolResult.content ?? toolResult.output ?? toolResult.result ?? toolResult;
|
|
341
|
+
}
|
|
342
|
+
else if (textBlock && textBlock.text != null) {
|
|
343
|
+
responseData =
|
|
344
|
+
typeof textBlock.text === 'string'
|
|
345
|
+
? textBlock.text
|
|
346
|
+
: (textBlock.text?.value ?? JSON.stringify(textBlock.text));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
responseData = content;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
responseData = content;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (responseData == null) {
|
|
357
|
+
responseData = messages.length > 0 ? messages : { thread_id: threadId, info: 'No tool output in messages.' };
|
|
358
|
+
}
|
|
359
|
+
return { data: responseData, threadId };
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Execute a Watson Orchestrate tool by name or ID.
|
|
363
|
+
* Resolves "News Search Tool" etc. to tool ID, ensures an agent has the tool, then invokes it.
|
|
364
|
+
*/
|
|
365
|
+
export async function executeTool(args) {
|
|
366
|
+
const { tool_name, tool_id, parameters = {}, agent_id } = args;
|
|
367
|
+
let resolvedId = null;
|
|
368
|
+
if (tool_id) {
|
|
369
|
+
resolvedId = tool_id;
|
|
370
|
+
}
|
|
371
|
+
else if (tool_name) {
|
|
372
|
+
resolvedId = await resolveToolByName(tool_name);
|
|
373
|
+
if (!resolvedId) {
|
|
374
|
+
throw new Error(`Tool not found: "${tool_name}". Use list_tools_with_connections or list_skills to see available tools.`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
throw new Error('Provide tool_name (e.g. "News Search Tool") or tool_id');
|
|
379
|
+
}
|
|
380
|
+
let agentId = agent_id;
|
|
381
|
+
if (!agentId) {
|
|
382
|
+
agentId = await ensureTestAgentForTool(resolvedId);
|
|
383
|
+
}
|
|
384
|
+
const { data, threadId } = await invokeToolRemote(resolvedId, parameters, agentId);
|
|
385
|
+
return {
|
|
386
|
+
success: true,
|
|
387
|
+
tool_id: resolvedId,
|
|
388
|
+
thread_id: threadId,
|
|
389
|
+
result: data,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* List all available tools/skills.
|
|
394
|
+
*/
|
|
395
|
+
export async function listSkills(limit = 100, offset = 0) {
|
|
396
|
+
console.error(`Listing skills (limit: ${limit}, offset: ${offset})...`);
|
|
397
|
+
try {
|
|
398
|
+
const response = await woFetch(`/v1/orchestrate/tools?limit=${limit}&offset=${offset}`, {
|
|
399
|
+
method: 'GET',
|
|
400
|
+
});
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
const text = await response.text();
|
|
403
|
+
throw new Error(`Failed to list tools: ${response.status} ${response.statusText} - ${text}`);
|
|
404
|
+
}
|
|
405
|
+
const text = await response.text();
|
|
406
|
+
try {
|
|
407
|
+
return JSON.parse(text);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return text;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
throw new Error(`API Request Failed: ${e.message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Get a specific skill by ID.
|
|
419
|
+
*/
|
|
420
|
+
export async function getSkill(skillId) {
|
|
421
|
+
console.error(`Getting skill ${skillId}...`);
|
|
422
|
+
try {
|
|
423
|
+
const response = await woFetch(`/v1/orchestrate/tools/${skillId}`, { method: 'GET' });
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
throw new Error(`Failed to get skill: ${response.status} ${response.statusText}`);
|
|
426
|
+
}
|
|
427
|
+
const text = await response.text();
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(text);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return text;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
throw new Error(`API Request Failed: ${e.message}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Delete a skill by ID.
|
|
441
|
+
*/
|
|
442
|
+
export async function deleteSkill(skillId) {
|
|
443
|
+
console.error(`Deleting skill ${skillId}...`);
|
|
444
|
+
try {
|
|
445
|
+
const response = await woFetch(`/v1/orchestrate/tools/${skillId}`, { method: 'DELETE' });
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
throw new Error(`Failed to delete skill: ${response.status} ${response.statusText}`);
|
|
448
|
+
}
|
|
449
|
+
return { success: true, message: `Skill ${skillId} deleted successfully.` };
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
throw new Error(`API Request Failed: ${e.message}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Deploy an OpenAPI skill. Uses buildToolSpec (same as extension) so:
|
|
457
|
+
* - connection_id from openapi_spec['x-ibm-connection-id'] is assigned to the tool
|
|
458
|
+
* - OpenAPI parameters, servers, security are properly mapped
|
|
459
|
+
*/
|
|
460
|
+
export async function deploySkill(args) {
|
|
461
|
+
const { toolSpec, openApiSpec } = args;
|
|
462
|
+
if (!toolSpec || !openApiSpec) {
|
|
463
|
+
throw new Error('Missing required arguments: toolSpec, openApiSpec');
|
|
464
|
+
}
|
|
465
|
+
const enrichedSpec = buildToolSpec(toolSpec, openApiSpec);
|
|
466
|
+
console.error(`Creating Tool "${toolSpec.name}" (connection_id: ${enrichedSpec.binding?.openapi?.connection_id ?? 'none'})...`);
|
|
467
|
+
const createRes = await woFetch('/v1/orchestrate/tools', {
|
|
468
|
+
method: 'POST',
|
|
469
|
+
body: JSON.stringify(enrichedSpec),
|
|
470
|
+
});
|
|
471
|
+
if (!createRes.ok) {
|
|
472
|
+
const text = await createRes.text();
|
|
473
|
+
throw new Error(`Failed to create tool: ${createRes.status} ${text}`);
|
|
474
|
+
}
|
|
475
|
+
const text = await createRes.text();
|
|
476
|
+
let toolData;
|
|
477
|
+
try {
|
|
478
|
+
toolData = JSON.parse(text);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
throw new Error(`Failed to parse tool response: ${text}`);
|
|
482
|
+
}
|
|
483
|
+
const toolId = toolData.id;
|
|
484
|
+
try {
|
|
485
|
+
const zipBuffer = await createOpenApiZip(openApiSpec);
|
|
486
|
+
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
|
|
487
|
+
const filename = `${toolId}.zip`;
|
|
488
|
+
const head = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/zip\r\n\r\n`;
|
|
489
|
+
const tail = `\r\n--${boundary}--\r\n`;
|
|
490
|
+
const body = Buffer.concat([Buffer.from(head), zipBuffer, Buffer.from(tail)]);
|
|
491
|
+
const uploadRes = await woFetch(`/v1/orchestrate/tools/${toolId}/upload`, {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
headers: {
|
|
494
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
495
|
+
},
|
|
496
|
+
body: body,
|
|
497
|
+
});
|
|
498
|
+
if (!uploadRes.ok) {
|
|
499
|
+
console.error('Artifact upload failed (tool still created):', uploadRes.status);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (uploadErr) {
|
|
503
|
+
console.error('Artifact upload error (tool still created):', uploadErr.message);
|
|
504
|
+
}
|
|
505
|
+
return { success: true, toolId };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Copy a tool: fetch it, convert to OpenAPI, deploy as a new tool.
|
|
509
|
+
* Optionally pass new_name (e.g. "MVKWeatherV2") to override the default copy name.
|
|
510
|
+
* Keeps connection and other parameters the same. Tool names must use only letters, digits, underscores.
|
|
511
|
+
*/
|
|
512
|
+
export async function copySkill(skillId, newName) {
|
|
513
|
+
const skill = await getSkill(skillId);
|
|
514
|
+
const openApiSpec = skillToOas(skill);
|
|
515
|
+
const defaultName = openApiSpec.info?.['x-ibm-skill-id'] || `${(skill?.name || 'tool').replace(/[^a-zA-Z0-9_]/g, '_')}_copy_v1`;
|
|
516
|
+
const toolName = newName ? sanitizeToolName(newName) : defaultName;
|
|
517
|
+
openApiSpec.info = openApiSpec.info || {};
|
|
518
|
+
openApiSpec.info['x-ibm-skill-id'] = toolName;
|
|
519
|
+
openApiSpec.info['x-ibm-skill-name'] = newName
|
|
520
|
+
? newName
|
|
521
|
+
: openApiSpec.info['x-ibm-skill-name'] || `${skill?.display_name || skill?.name || 'Copy'} (Copy)`;
|
|
522
|
+
const toolSpec = {
|
|
523
|
+
name: toolName,
|
|
524
|
+
description: openApiSpec.info?.description || skill?.description || 'Copy of existing tool',
|
|
525
|
+
};
|
|
526
|
+
const result = await deploySkill({ toolSpec, openApiSpec });
|
|
527
|
+
return { ...result, sourceSkillId: skillId, tool_name: toolName };
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Update a tool (name, display_name, description, permission, tags).
|
|
531
|
+
* Note: binding, input_schema, output_schema, connection_id are NOT editable after creation.
|
|
532
|
+
*/
|
|
533
|
+
export async function updateSkill(skillId, skillJson) {
|
|
534
|
+
const updatePayload = {};
|
|
535
|
+
if (skillJson.name) {
|
|
536
|
+
updatePayload.name = skillJson.name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[^a-zA-Z_]+/, '');
|
|
537
|
+
}
|
|
538
|
+
if (skillJson.display_name)
|
|
539
|
+
updatePayload.display_name = skillJson.display_name;
|
|
540
|
+
if (skillJson.description)
|
|
541
|
+
updatePayload.description = skillJson.description;
|
|
542
|
+
updatePayload.permission = skillJson.permission || 'read_write';
|
|
543
|
+
if (skillJson.restrictions)
|
|
544
|
+
updatePayload.restrictions = skillJson.restrictions;
|
|
545
|
+
if (skillJson.tags)
|
|
546
|
+
updatePayload.tags = skillJson.tags;
|
|
547
|
+
const response = await woFetch(`/v1/orchestrate/tools/${skillId}`, {
|
|
548
|
+
method: 'PUT',
|
|
549
|
+
body: JSON.stringify(updatePayload),
|
|
550
|
+
});
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
const text = await response.text();
|
|
553
|
+
throw new Error(`Failed to update skill: ${response.status} ${text}`);
|
|
554
|
+
}
|
|
555
|
+
if (response.status === 204)
|
|
556
|
+
return { success: true };
|
|
557
|
+
const text = await response.text();
|
|
558
|
+
if (!text)
|
|
559
|
+
return { success: true };
|
|
560
|
+
try {
|
|
561
|
+
return JSON.parse(text);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return { success: true };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Deploy a tool from a URL. Handles both: (1) URLs with API key -> creates connection + tool,
|
|
569
|
+
* (2) Public URLs without auth (REST Countries, Open-Meteo, etc.) -> creates standard tool.
|
|
570
|
+
*/
|
|
571
|
+
export async function deployToolFromUrl(args) {
|
|
572
|
+
const { url, tool_name, description = '' } = args;
|
|
573
|
+
const u = new URL(url);
|
|
574
|
+
const baseUrl = u.origin;
|
|
575
|
+
const pathFromUrl = u.pathname || '/';
|
|
576
|
+
const params = {};
|
|
577
|
+
u.searchParams.forEach((v, k) => {
|
|
578
|
+
params[k] = v;
|
|
579
|
+
});
|
|
580
|
+
const apiKeyNames = ['key', 'apiKey', 'api_key', 'apikey', 'token', 'auth'];
|
|
581
|
+
let apiKeyParam = '';
|
|
582
|
+
let apiKeyValue = '';
|
|
583
|
+
for (const n of apiKeyNames) {
|
|
584
|
+
if (params[n]) {
|
|
585
|
+
apiKeyParam = n;
|
|
586
|
+
apiKeyValue = params[n];
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!apiKeyParam && Object.keys(params).length > 0) {
|
|
591
|
+
const first = Object.keys(params)[0];
|
|
592
|
+
if (params[first]?.length >= 8 && /^[a-zA-Z0-9_-]+$/.test(params[first])) {
|
|
593
|
+
apiKeyParam = first;
|
|
594
|
+
apiKeyValue = params[first];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (!apiKeyParam || !apiKeyValue) {
|
|
598
|
+
return deployPublicToolFromUrl(args);
|
|
599
|
+
}
|
|
600
|
+
const hostPart = u.hostname.replace(/^api\./, '').replace(/\./g, '_');
|
|
601
|
+
const appId = `A1_${hostPart}`.substring(0, 64).replace(/[^a-zA-Z0-9_]/g, '_') || 'A1_api';
|
|
602
|
+
const displayName = (tool_name || hostPart || 'API').replace(/[^a-zA-Z0-9_\s-]/g, '');
|
|
603
|
+
try {
|
|
604
|
+
await createConnection({ app_id: appId, display_name: displayName || appId });
|
|
605
|
+
}
|
|
606
|
+
catch (e1) {
|
|
607
|
+
const msg = e1 instanceof Error ? e1.message : String(e1);
|
|
608
|
+
if (!msg?.includes('already exists'))
|
|
609
|
+
throw e1;
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
await createConnectionConfiguration(appId, 'draft', 'api_key', 'team', baseUrl);
|
|
613
|
+
}
|
|
614
|
+
catch (e2) {
|
|
615
|
+
const msg = e2 instanceof Error ? e2.message : String(e2);
|
|
616
|
+
if (!msg?.includes('already') && !msg?.includes('exist'))
|
|
617
|
+
throw e2;
|
|
618
|
+
}
|
|
619
|
+
await setApiKeyCredentials(appId, apiKeyValue, 'draft');
|
|
620
|
+
const connData = await getConnection(appId);
|
|
621
|
+
const apps = connData?.applications ?? (Array.isArray(connData) ? connData : connData ? [connData] : []);
|
|
622
|
+
const connId = apps[0]?.connection_id || apps[0]?.app_id || appId;
|
|
623
|
+
const openApiSpec = {
|
|
624
|
+
openapi: '3.0.1',
|
|
625
|
+
info: {
|
|
626
|
+
title: tool_name,
|
|
627
|
+
version: '1.0.0',
|
|
628
|
+
description: description || `Tool for ${baseUrl}`,
|
|
629
|
+
'x-ibm-skill-name': tool_name,
|
|
630
|
+
'x-ibm-skill-id': tool_name.replace(/[^a-zA-Z0-9_-]/g, '_'),
|
|
631
|
+
},
|
|
632
|
+
servers: [{ url: baseUrl }],
|
|
633
|
+
'x-ibm-connection-id': connId,
|
|
634
|
+
'x-ibm-security': [{ type: 'apiKey', in: 'query', name: apiKeyParam }],
|
|
635
|
+
paths: {
|
|
636
|
+
[pathFromUrl]: {
|
|
637
|
+
get: {
|
|
638
|
+
operationId: 'fetch',
|
|
639
|
+
summary: tool_name,
|
|
640
|
+
parameters: [
|
|
641
|
+
{
|
|
642
|
+
name: apiKeyParam,
|
|
643
|
+
in: 'query',
|
|
644
|
+
required: false,
|
|
645
|
+
schema: { type: 'string' },
|
|
646
|
+
description: 'API key (injected by connection)',
|
|
647
|
+
},
|
|
648
|
+
...Object.keys(params)
|
|
649
|
+
.filter((k) => k !== apiKeyParam)
|
|
650
|
+
.map((k) => ({
|
|
651
|
+
name: k,
|
|
652
|
+
in: 'query',
|
|
653
|
+
required: false,
|
|
654
|
+
schema: { type: 'string' },
|
|
655
|
+
description: '',
|
|
656
|
+
})),
|
|
657
|
+
],
|
|
658
|
+
responses: {
|
|
659
|
+
'200': {
|
|
660
|
+
description: 'Success',
|
|
661
|
+
content: { 'application/json': { schema: { type: 'object' } } },
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
const toolSpec = {
|
|
669
|
+
name: tool_name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[^a-zA-Z_]+/, ''),
|
|
670
|
+
description: description || `Tool for ${baseUrl}`,
|
|
671
|
+
tool_type: 'openapi',
|
|
672
|
+
permission: 'read_write',
|
|
673
|
+
};
|
|
674
|
+
const result = await deploySkill({ toolSpec, openApiSpec });
|
|
675
|
+
return { ...result, appId };
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Deploy a tool from a PUBLIC API URL (no authentication). Use for REST Countries, Open-Meteo, etc.
|
|
679
|
+
* Creates a standard tool with no connection.
|
|
680
|
+
*/
|
|
681
|
+
export async function deployPublicToolFromUrl(args) {
|
|
682
|
+
const { url, tool_name, description = '' } = args;
|
|
683
|
+
const u = new URL(url);
|
|
684
|
+
const baseUrl = u.origin;
|
|
685
|
+
let pathFromUrl = u.pathname || '/';
|
|
686
|
+
const segments = pathFromUrl.split('/').filter(Boolean);
|
|
687
|
+
const lastSegment = segments[segments.length - 1];
|
|
688
|
+
const isParam = lastSegment && !/^\d+$/.test(lastSegment) && lastSegment.length > 1;
|
|
689
|
+
const paramName = pathFromUrl.includes('name') ? 'name' : pathFromUrl.includes('id') ? 'id' : 'q';
|
|
690
|
+
if (isParam && segments.length > 0) {
|
|
691
|
+
segments[segments.length - 1] = `{${paramName}}`;
|
|
692
|
+
pathFromUrl = '/' + segments.join('/');
|
|
693
|
+
}
|
|
694
|
+
const pathParams = (pathFromUrl.match(/\{[^}]+\}/g) || []).map((p) => p.slice(1, -1));
|
|
695
|
+
const queryParams = [];
|
|
696
|
+
u.searchParams.forEach((v, k) => {
|
|
697
|
+
queryParams.push({ name: k, in: 'query', required: false, schema: { type: 'string' }, description: '' });
|
|
698
|
+
});
|
|
699
|
+
const parameters = [
|
|
700
|
+
...pathParams.map((name) => ({
|
|
701
|
+
name,
|
|
702
|
+
in: 'path',
|
|
703
|
+
required: true,
|
|
704
|
+
schema: { type: 'string' },
|
|
705
|
+
description: '',
|
|
706
|
+
})),
|
|
707
|
+
...queryParams,
|
|
708
|
+
];
|
|
709
|
+
if (parameters.length === 0) {
|
|
710
|
+
parameters.push({ name: 'q', in: 'query', required: false, schema: { type: 'string' }, description: 'Query' });
|
|
711
|
+
}
|
|
712
|
+
const openApiSpec = {
|
|
713
|
+
openapi: '3.0.1',
|
|
714
|
+
info: {
|
|
715
|
+
title: tool_name,
|
|
716
|
+
version: '1.0.0',
|
|
717
|
+
description: description || `Public API tool for ${baseUrl}`,
|
|
718
|
+
'x-ibm-skill-name': tool_name,
|
|
719
|
+
'x-ibm-skill-id': tool_name.replace(/[^a-zA-Z0-9_]/g, '_'),
|
|
720
|
+
},
|
|
721
|
+
servers: [{ url: baseUrl }],
|
|
722
|
+
paths: {
|
|
723
|
+
[pathFromUrl]: {
|
|
724
|
+
get: {
|
|
725
|
+
operationId: 'fetch',
|
|
726
|
+
summary: tool_name,
|
|
727
|
+
parameters,
|
|
728
|
+
responses: {
|
|
729
|
+
'200': {
|
|
730
|
+
description: 'Success',
|
|
731
|
+
content: { 'application/json': { schema: { type: 'object' } } },
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
const toolSpec = {
|
|
739
|
+
name: tool_name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[^a-zA-Z_]+/, ''),
|
|
740
|
+
description: description || `Public API tool for ${baseUrl}`,
|
|
741
|
+
tool_type: 'openapi',
|
|
742
|
+
permission: 'read_write',
|
|
743
|
+
};
|
|
744
|
+
const result = await deploySkill({ toolSpec, openApiSpec });
|
|
745
|
+
return { ...result, appId: '' };
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Create a tool from URL and assign to agent – ONE step. Use for "create REST Countries tool and assign to TimeWeatherAgent".
|
|
749
|
+
* Works with public APIs (no auth: REST Countries, Open-Meteo) and APIs with keys. Do NOT use ADK – use this MCP tool.
|
|
750
|
+
*/
|
|
751
|
+
export async function createToolAndAssignToAgent(args) {
|
|
752
|
+
const { assignToolToAgent } = await import('./agents.js');
|
|
753
|
+
const deployResult = await deployToolFromUrl({
|
|
754
|
+
url: args.url,
|
|
755
|
+
tool_name: args.tool_name,
|
|
756
|
+
description: args.description,
|
|
757
|
+
});
|
|
758
|
+
const assignResult = await assignToolToAgent({
|
|
759
|
+
tool_id: deployResult.toolId,
|
|
760
|
+
agent_id: args.agent_id,
|
|
761
|
+
agent_name: args.agent_name,
|
|
762
|
+
});
|
|
763
|
+
return {
|
|
764
|
+
success: true,
|
|
765
|
+
toolId: deployResult.toolId,
|
|
766
|
+
agent_id: assignResult.agent_id,
|
|
767
|
+
agent_name: assignResult.agent_name,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Test a URL locally (direct HTTP GET). Mirrors extension's "Run Local".
|
|
772
|
+
* Use to verify an API endpoint without going through Watson Orchestrate.
|
|
773
|
+
*/
|
|
774
|
+
export async function testToolLocal(args) {
|
|
775
|
+
const { url, params = {} } = args;
|
|
776
|
+
const u = new URL(url);
|
|
777
|
+
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
|
778
|
+
const targetUrl = u.toString();
|
|
779
|
+
const res = await fetch(targetUrl);
|
|
780
|
+
const text = await res.text();
|
|
781
|
+
let data;
|
|
782
|
+
try {
|
|
783
|
+
data = JSON.parse(text);
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
data = text;
|
|
787
|
+
}
|
|
788
|
+
return { success: res.ok, status: res.status, data };
|
|
789
|
+
}
|