loopengine 0.1.98 → 0.2.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.
Files changed (35) hide show
  1. package/dist/bin/ability-manager.d.ts +35 -2
  2. package/dist/bin/ability-manager.js +168 -35
  3. package/dist/core/ability-details.js +11 -0
  4. package/dist/core/mcp-abilities.d.ts +91 -0
  5. package/dist/core/mcp-abilities.js +400 -0
  6. package/dist/core/mcp-ability-generator.d.ts +59 -0
  7. package/dist/core/mcp-ability-generator.js +193 -0
  8. package/dist/core/mcp-apps.d.ts +95 -0
  9. package/dist/core/mcp-apps.js +254 -0
  10. package/dist/core/mcp-directory.d.ts +32 -0
  11. package/dist/core/mcp-directory.js +191 -0
  12. package/dist/core/run-agent.js +5 -2
  13. package/dist/core/system-agents/interviewer.d.ts +1 -1
  14. package/dist/core/system-agents/interviewer.js +1 -1
  15. package/dist/core/system-agents/templates.d.ts +11 -0
  16. package/dist/core/system-agents/templates.js +22 -16
  17. package/dist/core/system-agents/workflow-planner.d.ts +36 -3
  18. package/dist/core/system-agents/workflow-planner.js +23 -6
  19. package/dist/core/workspace-abilities.js +1 -1
  20. package/dist/index.d.ts +7 -3
  21. package/dist/index.js +6 -2
  22. package/dist/web/abilities-admin.d.ts +1 -1
  23. package/dist/web/abilities-admin.js +1 -1
  24. package/dist/web/agents-config-page.js +187 -11
  25. package/dist/web/console-shell.js +7 -5
  26. package/dist/web/dev-ui-styles.js +1 -1
  27. package/dist/web/global-config-page.js +293 -13
  28. package/dist/web/interview-admin.js +2 -0
  29. package/dist/web/templates-admin.d.ts +10 -2
  30. package/dist/web/templates-admin.js +36 -3
  31. package/dist/web/templates-page.js +1 -0
  32. package/dist/web/workflow-admin.d.ts +46 -0
  33. package/dist/web/workflow-admin.js +195 -3
  34. package/dist/web/workflow-page.js +33 -3
  35. package/package.json +10 -1
@@ -0,0 +1,400 @@
1
+ // Abilities whose tools come from an MCP server instead of tool files.
2
+ // The ability's loopengine.ability.json carries an `mcp` block (the
3
+ // server, how to authenticate, and an allowlist of its tools); its
4
+ // actauth rules and skills stay ordinary files, installed the usual way.
5
+ // add-ability records the block in the agent's .loopengine-abilities.json;
6
+ // at run time loadMcpAbilityTools turns each allowlisted server tool into
7
+ // a plain ToolDefinition, so permissions, the planner and the console
8
+ // treat it like any other ability tool.
9
+ //
10
+ // Tool schemas are cached in agents/<name>/.loopengine-mcp-cache.json
11
+ // once fetched, so an agent loads without a server round trip; a server
12
+ // connection is opened on first use and reused after that.
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17
+ import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
18
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
19
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
20
+ import { createAgentEnv } from './agent-env.js';
21
+ const TOOL_NAME = /^[a-z][a-z0-9_]*$/;
22
+ const DEFAULT_MAX_OUTPUT_CHARS = 20000;
23
+ export class McpManifestError extends Error {
24
+ }
25
+ /** Checks an ability's `mcp` block and works out its installed tool
26
+ * names. Throws McpManifestError on anything unusable, so a bad
27
+ * manifest fails at add-ability, not on the agent's first run. */
28
+ export function resolveMcpManifest(abilityName, mcp) {
29
+ if (!mcp || typeof mcp !== 'object')
30
+ throw new McpManifestError(`'mcp' must be an object.`);
31
+ const m = mcp;
32
+ if (!!m.url === !!m.command)
33
+ throw new McpManifestError(`'mcp' needs exactly one of 'url' (a remote server) or 'command' (a local one).`);
34
+ if (m.url) {
35
+ let parsed;
36
+ try {
37
+ parsed = new URL(m.url);
38
+ }
39
+ catch {
40
+ throw new McpManifestError(`'mcp.url' isn't a valid URL: ${m.url}`);
41
+ }
42
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname);
43
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local)) {
44
+ throw new McpManifestError(`'mcp.url' must be https (http is only allowed for localhost).`);
45
+ }
46
+ }
47
+ if (m.transport && m.transport !== 'streamable-http' && m.transport !== 'sse')
48
+ throw new McpManifestError(`'mcp.transport' must be 'streamable-http' or 'sse'.`);
49
+ if (m.auth && m.auth.type !== 'none' && m.auth.type !== 'key') {
50
+ throw new McpManifestError(`'mcp.auth.type' must be 'none' or 'key' — OAuth isn't supported yet.`);
51
+ }
52
+ if (m.auth?.type === 'key' && !m.auth.env)
53
+ throw new McpManifestError(`'mcp.auth' of type 'key' needs 'env', the setting that holds the key.`);
54
+ if (m.defaults !== undefined) {
55
+ const ok = m.defaults && typeof m.defaults === 'object' && Object.values(m.defaults).every((d) => typeof d === 'string' || (d && typeof d === 'object' && typeof d.env === 'string' && (d.part === undefined || Number.isInteger(d.part))));
56
+ if (!ok)
57
+ throw new McpManifestError(`'mcp.defaults' maps input names to a "\${SETTING}" string or { env, split?, part? }.`);
58
+ }
59
+ if (!Array.isArray(m.tools) || m.tools.length === 0 || !m.tools.every((t) => typeof t === 'string' && t)) {
60
+ throw new McpManifestError(`'mcp.tools' must list the server tools to expose.`);
61
+ }
62
+ const prefix = m.prefix ?? abilityName.replace(/^@[^/]+\//, '').replace(/^lp-/, '');
63
+ const tools = m.tools.map((remote) => ({ name: mcpToolName(prefix, remote), remote }));
64
+ const seen = new Set();
65
+ for (const tool of tools) {
66
+ if (!TOOL_NAME.test(tool.name))
67
+ throw new McpManifestError(`'${tool.remote}' doesn't make a valid tool name (got '${tool.name}').`);
68
+ if (seen.has(tool.name))
69
+ throw new McpManifestError(`Two tools in 'mcp.tools' both become '${tool.name}'.`);
70
+ seen.add(tool.name);
71
+ }
72
+ const { tools: _tools, prefix: _prefix, ...server } = m;
73
+ return { server, tools };
74
+ }
75
+ /** An installed tool's name: `<prefix>_<tool>` in snake_case, or just the
76
+ * tool's own name when it already carries the prefix as a word
77
+ * (`web_search_exa` stays as is for prefix `exa`). */
78
+ export function mcpToolName(prefix, remote) {
79
+ const own = snakeCase(remote);
80
+ const p = snakeCase(prefix);
81
+ return p && own.split('_').includes(p) ? own : snakeCase(`${prefix}_${remote}`);
82
+ }
83
+ function snakeCase(value) {
84
+ return value
85
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
86
+ .replace(/[^A-Za-z0-9]+/g, '_')
87
+ .replace(/^_+|_+$/g, '')
88
+ .toLowerCase();
89
+ }
90
+ // ---- Settings ----
91
+ /** `${NAME}` → the agent's setting. A missing setting is an error
92
+ * naming it, so the console's message says exactly what to add. */
93
+ function interpolate(value, env) {
94
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => env.require(name));
95
+ }
96
+ function interpolateAll(values, env) {
97
+ return Object.fromEntries(Object.entries(values ?? {}).map(([k, v]) => [k, interpolate(v, env)]));
98
+ }
99
+ function resolveServer(server, env) {
100
+ const headers = interpolateAll(server.headers, env);
101
+ if (server.auth?.type === 'key') {
102
+ const header = server.auth.header ?? 'Authorization';
103
+ const prefix = server.auth.prefix ?? (header.toLowerCase() === 'authorization' ? 'Bearer ' : '');
104
+ headers[header] = `${prefix}${env.require(server.auth.env)}`;
105
+ }
106
+ return {
107
+ url: server.url,
108
+ transport: server.transport,
109
+ headers,
110
+ command: server.command,
111
+ args: (server.args ?? []).map((a) => interpolate(a, env)),
112
+ env: interpolateAll(server.env, env),
113
+ };
114
+ }
115
+ // ---- Connections ----
116
+ // One client per agent folder, ability and resolved settings — a
117
+ // changed key gets a fresh connection instead of reusing the old one.
118
+ const clients = new Map();
119
+ function clientKey(dir, abilityName, server) {
120
+ return `${dir}\0${abilityName}\0${createHash('sha256').update(JSON.stringify(server)).digest('hex')}`;
121
+ }
122
+ async function connect(server) {
123
+ const client = new Client({ name: 'loopengine', version: '1.0.0' });
124
+ if (server.command) {
125
+ // getDefaultEnvironment is a small safe set (PATH, HOME, ...), not
126
+ // this process's whole environment, so a local server only sees the
127
+ // settings its manifest passes it.
128
+ await client.connect(new StdioClientTransport({ command: server.command, args: server.args, env: { ...getDefaultEnvironment(), ...server.env }, stderr: 'ignore' }));
129
+ return client;
130
+ }
131
+ const url = new URL(server.url);
132
+ const requestInit = { headers: server.headers };
133
+ if (server.transport === 'sse') {
134
+ await client.connect(new SSEClientTransport(url, { requestInit }));
135
+ return client;
136
+ }
137
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
138
+ return client;
139
+ }
140
+ function getClient(dir, abilityName, server) {
141
+ const key = clientKey(dir, abilityName, server);
142
+ let client = clients.get(key);
143
+ if (!client) {
144
+ client = connect(server);
145
+ clients.set(key, client);
146
+ // A failed connect isn't kept, so the next call tries again.
147
+ client.catch(() => clients.delete(key));
148
+ }
149
+ return client;
150
+ }
151
+ function dropClient(dir, abilityName, server) {
152
+ const key = clientKey(dir, abilityName, server);
153
+ const client = clients.get(key);
154
+ clients.delete(key);
155
+ client?.then((c) => c.close()).catch(() => { });
156
+ }
157
+ /** Closes every open server connection (and stops local servers). */
158
+ export async function closeMcpConnections() {
159
+ const open = [...clients.values()];
160
+ clients.clear();
161
+ await Promise.all(open.map((c) => c.then((client) => client.close()).catch(() => { })));
162
+ }
163
+ /** Whether a connection failed because the server wants (other)
164
+ * credentials: an HTTP 401/403, or the SDK's own UnauthorizedError. */
165
+ export function isMcpAuthError(err) {
166
+ const code = err?.code;
167
+ if (code === 401 || code === 403)
168
+ return true;
169
+ const e = err;
170
+ return e?.name === 'UnauthorizedError' || /\b(401|403)\b|unauthori[sz]ed|forbidden/i.test(String(e?.message ?? ''));
171
+ }
172
+ /** Every tool a server offers, over a one-off connection (closed after).
173
+ * `server` is an `mcp` block without its allowlist; settings in it are
174
+ * read from `env`. */
175
+ export async function listMcpServerTools(server, env) {
176
+ const client = await connect(resolveServer(server, env));
177
+ try {
178
+ const tools = [];
179
+ let cursor;
180
+ do {
181
+ const page = await client.listTools(cursor ? { cursor } : undefined);
182
+ for (const tool of page.tools) {
183
+ const hints = (tool.annotations ?? {});
184
+ tools.push({
185
+ name: tool.name,
186
+ description: tool.description ?? '',
187
+ inputSchema: tool.inputSchema,
188
+ ...(typeof hints.readOnlyHint === 'boolean' ? { readOnly: hints.readOnlyHint } : {}),
189
+ ...(typeof hints.destructiveHint === 'boolean' ? { destructive: hints.destructiveHint } : {}),
190
+ });
191
+ }
192
+ cursor = page.nextCursor;
193
+ } while (cursor && tools.length < 500);
194
+ return tools;
195
+ }
196
+ finally {
197
+ await client.close().catch(() => { });
198
+ }
199
+ }
200
+ function cachePath(dir) {
201
+ return join(dir, '.loopengine-mcp-cache.json');
202
+ }
203
+ function readCache(dir) {
204
+ try {
205
+ return JSON.parse(readFileSync(cachePath(dir), 'utf8'));
206
+ }
207
+ catch {
208
+ return {};
209
+ }
210
+ }
211
+ function writeCache(dir, cache) {
212
+ writeFileSync(cachePath(dir), JSON.stringify(cache, null, 2) + '\n');
213
+ }
214
+ /** Drops an ability's cached schemas — add-ability and upgrade-ability
215
+ * call this so a new version's tools are fetched fresh. */
216
+ export function clearMcpSchemaCache(dir, abilityName) {
217
+ if (!existsSync(cachePath(dir)))
218
+ return;
219
+ const cache = readCache(dir);
220
+ if (!(abilityName in cache))
221
+ return;
222
+ delete cache[abilityName];
223
+ writeCache(dir, cache);
224
+ }
225
+ /** Why an MCP ability's tools didn't load on the last attempt, by agent
226
+ * folder and ability — e.g. a key that isn't set yet. Cleared once they
227
+ * load. */
228
+ const loadErrors = new Map();
229
+ export function mcpAbilityLoadError(dir, abilityName) {
230
+ return loadErrors.get(`${dir}\0${abilityName}`);
231
+ }
232
+ /** Every tool of every MCP ability installed in `dir` (an agent's
233
+ * folder). An ability whose schemas aren't cached yet is connected to
234
+ * once to fetch them; if that fails (no key yet, server down) its tools
235
+ * are left out and the reason is kept for mcpAbilityLoadError, rather
236
+ * than failing the whole agent. */
237
+ export async function loadMcpAbilityTools(dir, agentName) {
238
+ const provenancePath = join(dir, '.loopengine-abilities.json');
239
+ if (!existsSync(provenancePath))
240
+ return [];
241
+ let provenance;
242
+ try {
243
+ provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
244
+ }
245
+ catch {
246
+ return [];
247
+ }
248
+ const entries = Object.entries(provenance).filter((e) => !!e[1].mcp);
249
+ if (entries.length === 0)
250
+ return [];
251
+ const cache = readCache(dir);
252
+ let cacheChanged = false;
253
+ const tools = [];
254
+ for (const [abilityName, record] of entries) {
255
+ const errorKey = `${dir}\0${abilityName}`;
256
+ let cached = cache[abilityName]?.version === record.version ? cache[abilityName].tools : undefined;
257
+ if (!cached || record.mcp.tools.some((t) => !cached[t.remote])) {
258
+ try {
259
+ cached = await fetchSchemas(dir, abilityName, record.mcp, createAgentEnv(agentName, dir));
260
+ cache[abilityName] = { version: record.version, tools: cached };
261
+ cacheChanged = true;
262
+ }
263
+ catch (err) {
264
+ loadErrors.set(errorKey, err instanceof Error ? err.message : String(err));
265
+ continue;
266
+ }
267
+ }
268
+ loadErrors.delete(errorKey);
269
+ const env = createAgentEnv(agentName, dir);
270
+ for (const tool of record.mcp.tools) {
271
+ const schema = cached[tool.remote];
272
+ if (schema)
273
+ tools.push(toolDefinition(dir, abilityName, record.mcp, tool, schema, env));
274
+ }
275
+ }
276
+ if (cacheChanged)
277
+ writeCache(dir, cache);
278
+ return tools;
279
+ }
280
+ async function fetchSchemas(dir, abilityName, mcp, env) {
281
+ const server = resolveServer(mcp.server, env);
282
+ const client = await getClient(dir, abilityName, server);
283
+ const wanted = new Set(mcp.tools.map((t) => t.remote));
284
+ const found = {};
285
+ let cursor;
286
+ do {
287
+ const page = await client.listTools(cursor ? { cursor } : undefined);
288
+ for (const tool of page.tools) {
289
+ if (wanted.has(tool.name))
290
+ found[tool.name] = { description: tool.description ?? '', input_schema: tool.inputSchema };
291
+ }
292
+ cursor = page.nextCursor;
293
+ } while (cursor);
294
+ const missing = mcp.tools.filter((t) => !found[t.remote]).map((t) => t.remote);
295
+ if (missing.length === mcp.tools.length)
296
+ throw new Error(`The ${abilityName} server has none of the listed tools (${missing.join(', ')}).`);
297
+ if (missing.length)
298
+ console.warn(`[loopengine] ${abilityName}: the server has no tool named ${missing.join(', ')} — left out.`);
299
+ return found;
300
+ }
301
+ /** Each `defaults` entry's value from the agent's settings; a setting
302
+ * that isn't set (or a missing part) leaves that input without one. */
303
+ function defaultValues(defaults, env) {
304
+ const out = {};
305
+ for (const [input, spec] of Object.entries(defaults ?? {})) {
306
+ let value;
307
+ if (typeof spec === 'string') {
308
+ let missing = false;
309
+ value = spec.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
310
+ const v = env.get(name);
311
+ if (v === undefined)
312
+ missing = true;
313
+ return v ?? '';
314
+ });
315
+ if (missing)
316
+ value = undefined;
317
+ }
318
+ else {
319
+ const raw = env.get(spec.env);
320
+ value = raw === undefined ? undefined : spec.split !== undefined ? raw.split(spec.split)[spec.part ?? 0] : raw;
321
+ }
322
+ if (value?.trim())
323
+ out[input] = value.trim();
324
+ }
325
+ return out;
326
+ }
327
+ /** The server's schema, with each input that has a default made optional
328
+ * and its description saying what it defaults to. */
329
+ function withDefaults(schema, values) {
330
+ const properties = (schema.properties ?? {});
331
+ const names = Object.keys(values).filter((n) => n in properties);
332
+ if (!names.length)
333
+ return schema;
334
+ const props = { ...properties };
335
+ for (const n of names)
336
+ props[n] = { ...props[n], description: `${props[n].description ? `${props[n].description}. ` : ''}Defaults to ${values[n]}.` };
337
+ const required = Array.isArray(schema.required) ? schema.required.filter((r) => !names.includes(r)) : schema.required;
338
+ return { ...schema, properties: props, ...(required !== undefined ? { required } : {}) };
339
+ }
340
+ function toolDefinition(dir, abilityName, mcp, tool, schema, env) {
341
+ const maxChars = mcp.server.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
342
+ const properties = (schema.input_schema.properties ?? {});
343
+ return {
344
+ name: tool.name,
345
+ description: schema.description || tool.remote,
346
+ input_schema: env ? withDefaults(schema.input_schema, defaultValues(mcp.server.defaults, env)) : schema.input_schema,
347
+ async execute(input, ctx) {
348
+ const server = resolveServer(mcp.server, ctx.env);
349
+ const args = { ...input };
350
+ for (const [name, value] of Object.entries(defaultValues(mcp.server.defaults, ctx.env))) {
351
+ if (name in properties && (args[name] === undefined || args[name] === ''))
352
+ args[name] = value;
353
+ }
354
+ const call = async () => (await getClient(dir, abilityName, server)).callTool({ name: tool.remote, arguments: args });
355
+ let result;
356
+ try {
357
+ result = await call();
358
+ }
359
+ catch (err) {
360
+ // A dropped connection (a restarted server, an expired session)
361
+ // gets one fresh retry; anything else is the tool's own error.
362
+ if (!isConnectionError(err))
363
+ throw err;
364
+ dropClient(dir, abilityName, server);
365
+ result = await call();
366
+ }
367
+ const text = resultText(result);
368
+ if (result.isError)
369
+ throw new Error(text || `${tool.remote} failed`);
370
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n\n[cut off at ${maxChars} characters]` : text;
371
+ },
372
+ };
373
+ }
374
+ function isConnectionError(err) {
375
+ const message = err instanceof Error ? err.message : String(err);
376
+ return /closed|ECONNRESET|ECONNREFUSED|EPIPE|not connected|session/i.test(message);
377
+ }
378
+ /** A tool result as text for the model: structured content as JSON when
379
+ * the server gives it, otherwise the text parts, with anything else
380
+ * (images, audio) noted rather than dropped silently. */
381
+ function resultText(result) {
382
+ if (result.structuredContent !== undefined)
383
+ return JSON.stringify(result.structuredContent);
384
+ if ('toolResult' in result)
385
+ return typeof result.toolResult === 'string' ? result.toolResult : JSON.stringify(result.toolResult);
386
+ const content = Array.isArray(result.content) ? result.content : [];
387
+ return content
388
+ .map((part) => {
389
+ if (part.type === 'text')
390
+ return String(part.text ?? '');
391
+ if (part.type === 'resource') {
392
+ const resource = part.resource;
393
+ return resource?.text ?? `[resource ${resource?.uri ?? ''}]`;
394
+ }
395
+ if (part.type === 'resource_link')
396
+ return `[link ${String(part.uri ?? '')}]`;
397
+ return `[${String(part.type)} omitted]`;
398
+ })
399
+ .join('\n\n');
400
+ }
@@ -0,0 +1,59 @@
1
+ import type { ModelCall } from './run-agent.js';
2
+ import { type McpToolInfo } from './mcp-abilities.js';
3
+ import type { McpDirectoryServer } from './mcp-directory.js';
4
+ export type Decision = 'allow' | 'ask';
5
+ export interface McpAbilityStep {
6
+ /** The step's place in the workflow (0-based). */
7
+ index: number;
8
+ title: string;
9
+ kind: 'tool' | 'ask' | 'approval' | 'other';
10
+ detail?: string;
11
+ /** The server tool the planner named, if it could see them. */
12
+ tool?: string;
13
+ }
14
+ export interface McpAbilityRequest {
15
+ server: McpDirectoryServer;
16
+ key?: string;
17
+ /** Steps the app is used for, to work out each one's tool (workflow
18
+ * builder); none when it's connected from the Apps page. */
19
+ workflow?: {
20
+ name: string;
21
+ summary: string;
22
+ steps: McpAbilityStep[];
23
+ };
24
+ /** Matches steps to tools and writes the skill; without one, steps
25
+ * match only by the tool the planner named, and the skill is plainer. */
26
+ modelCall?: ModelCall;
27
+ }
28
+ export interface GeneratedMcpAbility {
29
+ /** A temporary folder holding the ability package. */
30
+ dir: string;
31
+ /** Its package name, e.g. mcp-linear. */
32
+ name: string;
33
+ /** The setting its key is read from, when it uses one. */
34
+ keyEnv?: string;
35
+ /** Every tool, with its default permission. */
36
+ tools: {
37
+ name: string;
38
+ remote: string;
39
+ decision: Decision;
40
+ }[];
41
+ /** Step index → the server tool that does it. */
42
+ stepTools: Record<number, string>;
43
+ }
44
+ export declare class McpAbilityGenerationError extends Error {
45
+ }
46
+ /** Names for a server's ability: mcp-<slug>, tools <prefix>_<tool>, key <PREFIX>_MCP_KEY. */
47
+ export declare function mcpAbilityNames(server: Pick<McpDirectoryServer, 'id' | 'title'>): {
48
+ name: string;
49
+ prefix: string;
50
+ keyEnv: string;
51
+ };
52
+ /** A tool's default permission: Allow for a read-only tool, Ask me for
53
+ * anything else. */
54
+ export declare function defaultMcpDecision(tool: McpToolInfo): Decision;
55
+ /** Builds the app's ability package from `request.server`. Throws
56
+ * McpAbilityGenerationError when the server can't be reached or used. */
57
+ export declare function generateMcpAbility(request: McpAbilityRequest): Promise<GeneratedMcpAbility>;
58
+ /** The name of an app ability's rule for one of its tools. */
59
+ export declare function appRuleName(abilityName: string, toolName: string): string;
@@ -0,0 +1,193 @@
1
+ // Makes an app's ability from its MCP server: connects (with the key the
2
+ // person gave, if any), lists every tool the server offers, gives each a
3
+ // default permission, writes a skill, and lays it all out as an ability
4
+ // package (the `mcp` block of core/mcp-abilities.ts, actauth rules, a
5
+ // SKILL.md). The caller stores it as a workspace ability: the app, as the
6
+ // Apps page shows it.
7
+ //
8
+ // The package lists every tool; an agent that adds the app chooses which
9
+ // of them it gets and with what permission (installAbility's mcpTools).
10
+ // For the workflow builder it also works out which tool does each step
11
+ // the app is used for, so Create can add exactly those.
12
+ //
13
+ // Default permissions lean strict: a tool runs on its own only when the
14
+ // server marks it read-only (or, with no mark, its name reads as a
15
+ // lookup); anything else, and anything marked destructive, asks.
16
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { stringify as stringifyYaml } from 'yaml';
20
+ import { isReadOnlyToolName } from './gateway-tools.js';
21
+ import { isMcpAuthError, listMcpServerTools, mcpToolName } from './mcp-abilities.js';
22
+ export class McpAbilityGenerationError extends Error {
23
+ }
24
+ function slug(value) {
25
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, '') || 'server';
26
+ }
27
+ const snake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
28
+ /** Names for a server's ability: mcp-<slug>, tools <prefix>_<tool>, key <PREFIX>_MCP_KEY. */
29
+ export function mcpAbilityNames(server) {
30
+ const base = slug(server.title || server.id.split('/').pop() || server.id);
31
+ const prefix = snake(base);
32
+ return { name: `mcp-${base}`, prefix, keyEnv: `${prefix.toUpperCase()}_MCP_KEY` };
33
+ }
34
+ function isReadOnly(tool) {
35
+ return tool.readOnly ?? (tool.destructive ? false : isReadOnlyToolName(snake(tool.name)));
36
+ }
37
+ /** A tool's default permission: Allow for a read-only tool, Ask me for
38
+ * anything else. */
39
+ export function defaultMcpDecision(tool) {
40
+ return tool.destructive || !isReadOnly(tool) ? 'ask' : 'allow';
41
+ }
42
+ function envWith(name, value) {
43
+ const get = (n) => (n === name ? value : undefined);
44
+ return {
45
+ get,
46
+ require(n) {
47
+ const v = get(n);
48
+ if (v === undefined)
49
+ throw new McpAbilityGenerationError(`${n} is needed`);
50
+ return v;
51
+ },
52
+ };
53
+ }
54
+ /** Builds the app's ability package from `request.server`. Throws
55
+ * McpAbilityGenerationError when the server can't be reached or used. */
56
+ export async function generateMcpAbility(request) {
57
+ const { server } = request;
58
+ const steps = request.workflow?.steps ?? [];
59
+ const key = request.key?.trim() || undefined;
60
+ const names = mcpAbilityNames(server);
61
+ const keyEnv = key ? names.keyEnv : undefined;
62
+ const serverBlock = {
63
+ url: server.url,
64
+ ...(server.headers ? { headers: server.headers } : {}),
65
+ ...(server.transport === 'sse' ? { transport: 'sse' } : {}),
66
+ auth: keyEnv ? { type: 'key', env: keyEnv, ...(server.auth?.header ? { header: server.auth.header } : {}), ...(server.auth?.prefix !== undefined ? { prefix: server.auth.prefix } : {}) } : { type: 'none' },
67
+ };
68
+ let available;
69
+ try {
70
+ available = await listMcpServerTools(serverBlock, envWith(keyEnv, key));
71
+ }
72
+ catch (err) {
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ if (isMcpAuthError(err)) {
75
+ throw new McpAbilityGenerationError(key
76
+ ? `${server.title} turned down the key.`
77
+ : `${server.title} needs a key to connect (or a sign-in, which LoopEngine doesn't support yet). Add its API key and try again.`);
78
+ }
79
+ throw new McpAbilityGenerationError(`Couldn't connect to ${server.title} (${server.url}): ${message}`);
80
+ }
81
+ if (!available.length)
82
+ throw new McpAbilityGenerationError(`${server.title} offers no tools.`);
83
+ // Two server tools that would get the same installed name: keep the first.
84
+ const seen = new Set();
85
+ available = available.filter((t) => {
86
+ const installed = mcpToolName(names.prefix, t.name);
87
+ if (seen.has(installed))
88
+ return false;
89
+ seen.add(installed);
90
+ return true;
91
+ });
92
+ const byName = new Map(available.map((t) => [t.name, t]));
93
+ const choice = request.modelCall ? await chooseWithModel(request.modelCall, server, request.workflow, available).catch(() => undefined) : undefined;
94
+ const stepTools = {};
95
+ for (const step of steps) {
96
+ const picked = choice?.steps[step.index] ?? (step.tool && byName.has(step.tool) ? step.tool : undefined);
97
+ if (picked && byName.has(picked))
98
+ stepTools[step.index] = picked;
99
+ }
100
+ const toolName = (remote) => mcpToolName(names.prefix, remote);
101
+ const tools = available.map((t) => ({ name: toolName(t.name), remote: t.name, decision: defaultMcpDecision(t) }));
102
+ // The model writes the server's own tool names; the agent sees the installed ones.
103
+ let skillBody = choice?.skill.body || plainSkill(server, tools, byName);
104
+ for (const t of tools) {
105
+ if (t.remote !== t.name)
106
+ skillBody = skillBody.replace(new RegExp(`(?<![A-Za-z0-9_])${t.remote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_])`, 'g'), t.name);
107
+ }
108
+ const skillDescription = choice?.skill.description || `How to use ${server.title} (its MCP server): which tool does what, and when to ask first.`;
109
+ const dir = mkdtempSync(join(tmpdir(), 'loopengine-mcp-generated-'));
110
+ const skillId = slug(names.prefix);
111
+ mkdirSync(join(dir, 'actauth'), { recursive: true });
112
+ mkdirSync(join(dir, 'skills', skillId), { recursive: true });
113
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({
114
+ name: names.name,
115
+ version: '1.0.0',
116
+ description: `${server.title}${server.description ? `: ${server.description}` : ''} (made from its MCP server)`.slice(0, 300),
117
+ private: true,
118
+ keywords: ['loopengine-ability', 'mcp'],
119
+ }, null, 2) + '\n');
120
+ writeFileSync(join(dir, 'loopengine.ability.json'), JSON.stringify({
121
+ loopengineVersion: '>=0.1.99',
122
+ mcp: { ...serverBlock, tools: tools.map((t) => t.remote), prefix: names.prefix },
123
+ skills: [`skills/${skillId}`],
124
+ actauth: 'actauth/rules.yml',
125
+ env: keyEnv ? [{ name: keyEnv, description: `API key for ${server.title}'s MCP server`, secret: true, scope: 'overridable' }] : [],
126
+ generatedFrom: { server: server.id, url: server.url, source: server.source },
127
+ }, null, 2) + '\n');
128
+ writeFileSync(join(dir, 'actauth', 'rules.yml'), stringifyYaml(tools.map((t) => ({ name: appRuleName(names.name, t.name), scope: '*/*', tool: t.name, decision: t.decision }))));
129
+ writeFileSync(join(dir, 'skills', skillId, 'SKILL.md'), `---\nname: ${skillId}\ndescription: ${JSON.stringify(skillDescription.slice(0, 300))}\n---\n\n${skillBody.trim()}\n`);
130
+ return { dir, name: names.name, ...(keyEnv ? { keyEnv } : {}), tools, stepTools };
131
+ }
132
+ /** The name of an app ability's rule for one of its tools. */
133
+ export function appRuleName(abilityName, toolName) {
134
+ return `${abilityName}-${toolName.replace(/_/g, '-')}`.slice(0, 80);
135
+ }
136
+ const PLAIN_SKILL_TOOLS = 30;
137
+ function plainSkill(server, tools, byName) {
138
+ const shown = tools.slice(0, PLAIN_SKILL_TOOLS);
139
+ const lines = shown.map((t) => `- \`${t.name}\`: ${(byName.get(t.remote)?.description || t.remote).split('\n')[0].slice(0, 160)}`);
140
+ const more = tools.length > shown.length ? `\n\n…and ${tools.length - shown.length} more; each tool's description says what it does.` : '';
141
+ return `# ${server.title}\n\nTools from ${server.title}'s MCP server. You only have the ones this agent was given.\n\n${lines.join('\n')}${more}\n\nUse the search and lookup tools to find exact ids before creating or changing anything. A tool set to ask waits for a person's OK.`;
142
+ }
143
+ const CHOOSER_PROMPT = `You set up an app's MCP server for LoopEngine agents.
144
+
145
+ You get the app, every tool its server offers (with its hints: readOnly, destructive), and, when it's being set up for a workflow, the steps that use it. Call save_ability once with:
146
+ - steps: for each step listed, the one server tool that does it (its exact name). Empty when no steps are listed.
147
+ - skill: a short general guide to using this app's tools well (under 1200 characters): which tool to use for what, what ids to look up first, and that tools set to ask wait for a person. Agents are given only some of the tools, so don't rely on any one being there. description: one sentence.`;
148
+ const CHOOSER_TOOL = {
149
+ name: 'save_ability',
150
+ description: 'Save the tool for each step, and the skill. Call once.',
151
+ input_schema: {
152
+ type: 'object',
153
+ properties: {
154
+ steps: { type: 'array', items: { type: 'object', properties: { step: { type: 'number' }, tool: { type: 'string' } }, required: ['step', 'tool'] } },
155
+ skill: { type: 'object', properties: { description: { type: 'string' }, body: { type: 'string' } }, required: ['description', 'body'] },
156
+ },
157
+ required: ['steps', 'skill'],
158
+ },
159
+ };
160
+ async function chooseWithModel(modelCall, server, workflow, available) {
161
+ const toolList = available.slice(0, 200).map((t) => ({
162
+ name: t.name,
163
+ description: t.description.split('\n')[0].slice(0, 240),
164
+ ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}),
165
+ ...(t.destructive ? { destructive: true } : {}),
166
+ }));
167
+ const content = [
168
+ `App: ${server.title} (${server.url})`,
169
+ workflow?.steps.length
170
+ ? `Workflow: ${workflow.name}: ${workflow.summary}\n\nSteps that use it:\n${workflow.steps.map((s) => `- step ${s.index}: ${s.title}${s.detail ? `: ${s.detail}` : ''}${s.kind === 'approval' ? ' (a person approves first)' : ''}${s.tool ? ` (planner suggested ${s.tool})` : ''}`).join('\n')}`
171
+ : 'No workflow: the app is being connected for the workspace. Leave steps empty.',
172
+ `Its tools:\n${JSON.stringify(toolList)}`,
173
+ ].join('\n\n');
174
+ for (let attempt = 0; attempt < 2; attempt++) {
175
+ const response = await modelCall([{ role: 'user', content: attempt ? `${content}\n\nCall save_ability now.` : content }], CHOOSER_PROMPT, [CHOOSER_TOOL]);
176
+ const call = response.content.find((b) => b.type === 'tool_use' && b.name === 'save_ability');
177
+ if (!call)
178
+ continue;
179
+ const input = (call.input ?? {});
180
+ const steps = {};
181
+ for (const s of Array.isArray(input.steps) ? input.steps : []) {
182
+ const e = (s ?? {});
183
+ if (typeof e.step === 'number' && typeof e.tool === 'string')
184
+ steps[e.step] = e.tool;
185
+ }
186
+ const skill = (input.skill ?? {});
187
+ return {
188
+ steps,
189
+ skill: { description: typeof skill.description === 'string' ? skill.description : '', body: typeof skill.body === 'string' ? skill.body.slice(0, 4000) : '' },
190
+ };
191
+ }
192
+ return undefined;
193
+ }