loopengine 0.1.98 → 0.1.99
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/bin/ability-manager.d.ts +35 -2
- package/dist/bin/ability-manager.js +168 -35
- package/dist/core/ability-details.js +11 -0
- package/dist/core/mcp-abilities.d.ts +91 -0
- package/dist/core/mcp-abilities.js +400 -0
- package/dist/core/mcp-ability-generator.d.ts +59 -0
- package/dist/core/mcp-ability-generator.js +193 -0
- package/dist/core/mcp-apps.d.ts +95 -0
- package/dist/core/mcp-apps.js +254 -0
- package/dist/core/mcp-directory.d.ts +32 -0
- package/dist/core/mcp-directory.js +191 -0
- package/dist/core/run-agent.js +5 -2
- package/dist/core/system-agents/interviewer.d.ts +1 -1
- package/dist/core/system-agents/interviewer.js +1 -1
- package/dist/core/system-agents/templates.d.ts +11 -0
- package/dist/core/system-agents/templates.js +22 -16
- package/dist/core/system-agents/workflow-planner.d.ts +36 -3
- package/dist/core/system-agents/workflow-planner.js +23 -6
- package/dist/core/workspace-abilities.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/web/abilities-admin.d.ts +1 -1
- package/dist/web/abilities-admin.js +1 -1
- package/dist/web/agents-config-page.js +187 -11
- package/dist/web/console-shell.js +7 -5
- package/dist/web/dev-ui-styles.js +1 -1
- package/dist/web/global-config-page.js +293 -13
- package/dist/web/interview-admin.js +2 -0
- package/dist/web/templates-admin.d.ts +10 -2
- package/dist/web/templates-admin.js +36 -3
- package/dist/web/templates-page.js +1 -0
- package/dist/web/workflow-admin.d.ts +46 -0
- package/dist/web/workflow-admin.js +195 -3
- package/dist/web/workflow-page.js +33 -3
- package/package.json +10 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EnvScope } from '../core/agent-env.js';
|
|
2
|
+
import { type InstalledMcpRecord } from '../core/mcp-abilities.js';
|
|
2
3
|
export declare class AbilityManifestError extends Error {
|
|
3
4
|
}
|
|
4
5
|
export declare class AbilityVersionError extends Error {
|
|
@@ -55,6 +56,9 @@ export interface AbilityManifest {
|
|
|
55
56
|
version: string;
|
|
56
57
|
loopengineVersion: string;
|
|
57
58
|
tools?: string[];
|
|
59
|
+
/** Tools served by an MCP server instead of tool files — see
|
|
60
|
+
* core/mcp-abilities.ts's McpServerManifest. */
|
|
61
|
+
mcp?: unknown;
|
|
58
62
|
skills?: string[];
|
|
59
63
|
actauth?: string;
|
|
60
64
|
env?: AbilityEnvDecl[];
|
|
@@ -86,6 +90,10 @@ export interface InstalledAbilityRecord {
|
|
|
86
90
|
* has no recorded value — see oldContentSpec's own fallback. */
|
|
87
91
|
spec?: string;
|
|
88
92
|
tools: string[];
|
|
93
|
+
/** An MCP ability's server and tools (core/mcp-abilities.ts loads
|
|
94
|
+
* them at run time). Its tools have no files, so they aren't in
|
|
95
|
+
* `tools`. */
|
|
96
|
+
mcp?: InstalledMcpRecord;
|
|
89
97
|
skills: string[];
|
|
90
98
|
actauthRules: string[];
|
|
91
99
|
env: AbilityEnvDecl[];
|
|
@@ -178,13 +186,24 @@ export interface FetchOptions {
|
|
|
178
186
|
*
|
|
179
187
|
* All-or-nothing: every collision check below runs, and the whole
|
|
180
188
|
* install is refused, before a single file is written. */
|
|
181
|
-
|
|
189
|
+
/** For an ability that takes its tools from an MCP server: which of its
|
|
190
|
+
* server tools this agent gets (by the server's names), and each one's
|
|
191
|
+
* permission when it shouldn't be the ability's default. An app's ability
|
|
192
|
+
* (made from its server, listing every tool) given no choice gets just
|
|
193
|
+
* its read-only tools, those it allows by default. */
|
|
194
|
+
export interface McpToolChoice {
|
|
195
|
+
mcpTools?: string[];
|
|
196
|
+
mcpDecisions?: Record<string, 'allow' | 'ask' | 'deny'>;
|
|
197
|
+
}
|
|
198
|
+
export declare function installAbility(agentName: string, spec: string, options?: FetchOptions & McpToolChoice): Promise<{
|
|
182
199
|
installed: string[];
|
|
183
200
|
dependencies: string[];
|
|
184
201
|
}>;
|
|
185
202
|
export interface UpgradeFileResult {
|
|
186
203
|
path: string;
|
|
187
|
-
|
|
204
|
+
/** added/removed: a rule the new version adds, or a tool file it no
|
|
205
|
+
* longer has (e.g. once its tools come from an MCP server). */
|
|
206
|
+
status: 'updated' | 'unchanged' | 'conflict' | 'added' | 'removed';
|
|
188
207
|
}
|
|
189
208
|
/** Upgrades an already-installed ability to whatever `spec` (defaulting
|
|
190
209
|
* to `abilityName`, i.e. "latest") currently resolves to. Tool and skill
|
|
@@ -203,6 +222,20 @@ export declare function upgradeAbility(agentName: string, abilityName: string, o
|
|
|
203
222
|
}): Promise<{
|
|
204
223
|
files: UpgradeFileResult[];
|
|
205
224
|
}>;
|
|
225
|
+
/** Sets which of an MCP ability's server tools an agent has, and each
|
|
226
|
+
* one's permission for that agent. `abilityDir` is the ability's folder
|
|
227
|
+
* (an app's workspace copy), which lists every tool. A tool's permission
|
|
228
|
+
* is its first rule in the agent's actauth.yml (an agent's own rule for a
|
|
229
|
+
* tool is changed rather than shadowed); a tool with no rule gets the
|
|
230
|
+
* ability's. Rules the ability added for tools taken away go with them. */
|
|
231
|
+
export declare function configureAgentMcpTools(agentName: string, abilityName: string, abilityDir: string, choice: {
|
|
232
|
+
remote: string;
|
|
233
|
+
decision: 'allow' | 'ask' | 'deny';
|
|
234
|
+
}[]): {
|
|
235
|
+
added: string[];
|
|
236
|
+
removed: string[];
|
|
237
|
+
changed: string[];
|
|
238
|
+
};
|
|
206
239
|
/** Removes an installed ability's tool and skill files, and its actauth
|
|
207
240
|
* rules. A file whose content no longer matches the hash recorded at
|
|
208
241
|
* install/last-upgrade is refused (reported in `refused`, left on disk)
|
|
@@ -26,6 +26,8 @@ import { parse as parseYaml } from 'yaml';
|
|
|
26
26
|
import semver from 'semver';
|
|
27
27
|
import { agentDir } from '../core/gateway-tools.js';
|
|
28
28
|
import { abilityFetchAuth } from '../core/ability-sources.js';
|
|
29
|
+
import { clearMcpSchemaCache, resolveMcpManifest, McpManifestError } from '../core/mcp-abilities.js';
|
|
30
|
+
import { appRuleName } from '../core/mcp-ability-generator.js';
|
|
29
31
|
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
30
32
|
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
31
33
|
export class AbilityManifestError extends Error {
|
|
@@ -350,36 +352,16 @@ function parseActauthRules(abilityDir, manifest) {
|
|
|
350
352
|
return parsed;
|
|
351
353
|
}
|
|
352
354
|
// ---- Install ----
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
* admin-created tool — that works there because the HTTP request
|
|
364
|
-
* creating the tool runs *inside the same process* as the already-running
|
|
365
|
-
* server, so updateAgent's mutation is visible to every later request in
|
|
366
|
-
* that same process immediately. `add-ability` is a separate, one-off
|
|
367
|
-
* CLI process with no connection to whatever server might be running
|
|
368
|
-
* elsewhere — even a successful getEntry/updateAgent call here would
|
|
369
|
-
* only mutate *this* CLI invocation's own throwaway registry, then
|
|
370
|
-
* vanish the moment the process exits, having done nothing to the real
|
|
371
|
-
* running server. (Confirmed live: this used to import
|
|
372
|
-
* core/agent-registry.js, whose own discoverAgents does a top-level
|
|
373
|
-
* directory scan relative to *its own compiled file's location* —
|
|
374
|
-
* inside node_modules/loopengine when this runs there, not the
|
|
375
|
-
* consuming project's real agents/ at all, throwing ENOENT immediately.)
|
|
376
|
-
* Same reason add-agent/add-subagent above don't attempt this either —
|
|
377
|
-
* a new tool becomes active the same way a new agent does: the next
|
|
378
|
-
* request under `serve`, or automatically under `npx loopengine dev`'s
|
|
379
|
-
* file watcher once tools/index.ts's own edit is picked up.
|
|
380
|
-
*
|
|
381
|
-
* All-or-nothing: every collision check below runs, and the whole
|
|
382
|
-
* install is refused, before a single file is written. */
|
|
355
|
+
function resolveMcp(manifest) {
|
|
356
|
+
try {
|
|
357
|
+
return resolveMcpManifest(manifest.name, manifest.mcp);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (err instanceof McpManifestError)
|
|
361
|
+
throw new AbilityManifestError(err.message);
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
383
365
|
export async function installAbility(agentName, spec, options = {}) {
|
|
384
366
|
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
385
367
|
const abilityDir = fetch(spec);
|
|
@@ -391,7 +373,7 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
391
373
|
}
|
|
392
374
|
const toolFiles = manifest.tools ?? [];
|
|
393
375
|
const skillDirs = manifest.skills ?? [];
|
|
394
|
-
|
|
376
|
+
let rules = parseActauthRules(abilityDir, manifest);
|
|
395
377
|
const toolsDir = join(agentDir(agentName), 'tools');
|
|
396
378
|
const skillsDirPath = join(agentDir(agentName), 'skills');
|
|
397
379
|
// Collision checks — refuse the whole install before writing anything,
|
|
@@ -415,6 +397,39 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
415
397
|
}
|
|
416
398
|
toolNames.push(installedToolName);
|
|
417
399
|
}
|
|
400
|
+
const mcp = manifest.mcp === undefined ? undefined : resolveMcp(manifest);
|
|
401
|
+
if (mcp) {
|
|
402
|
+
const chosen = options.mcpTools ?? (manifest.generatedFrom ? mcp.tools.filter((t) => rules.some((r) => r.tool === t.name && r.decision === 'allow')).map((t) => t.remote) : undefined);
|
|
403
|
+
if (chosen) {
|
|
404
|
+
const unknown = chosen.filter((r) => !mcp.tools.some((t) => t.remote === r));
|
|
405
|
+
if (unknown.length)
|
|
406
|
+
throw new AbilityManifestError(`${manifest.name} has no tool named ${unknown.join(', ')}.`);
|
|
407
|
+
const dropped = new Set(mcp.tools.filter((t) => !chosen.includes(t.remote)).map((t) => t.name));
|
|
408
|
+
mcp.tools = mcp.tools.filter((t) => chosen.includes(t.remote));
|
|
409
|
+
if (!mcp.tools.length)
|
|
410
|
+
throw new AbilityManifestError(`Choose at least one of ${manifest.name}'s tools.`);
|
|
411
|
+
rules = rules.filter((r) => !dropped.has(r.tool));
|
|
412
|
+
}
|
|
413
|
+
for (const [remote, decision] of Object.entries(options.mcpDecisions ?? {})) {
|
|
414
|
+
const tool = mcp.tools.find((t) => t.remote === remote);
|
|
415
|
+
if (!tool)
|
|
416
|
+
continue;
|
|
417
|
+
const rule = rules.find((r) => r.tool === tool.name);
|
|
418
|
+
if (rule)
|
|
419
|
+
rule.decision = decision;
|
|
420
|
+
else
|
|
421
|
+
rules.push({ name: appRuleName(manifest.name, tool.name), scope: '*/*', tool: tool.name, decision });
|
|
422
|
+
}
|
|
423
|
+
// No namespacing fallback here, unlike tool files: the ability's own
|
|
424
|
+
// actauth rules name these tools, so a renamed tool would silently
|
|
425
|
+
// lose its rule.
|
|
426
|
+
const taken = new Set([...toolNames, ...Object.values(provenance).flatMap((r) => r.mcp?.tools.map((t) => t.name) ?? [])]);
|
|
427
|
+
for (const tool of mcp.tools) {
|
|
428
|
+
if (taken.has(tool.name) || existsSync(join(toolsDir, `${tool.name}.ts`))) {
|
|
429
|
+
throw new AbilityCollisionError(`Agent '${agentName}' already has a tool named '${tool.name}'.`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
418
433
|
const skillIds = [];
|
|
419
434
|
for (const skillDir of skillDirs) {
|
|
420
435
|
const bareSkillId = basename(skillDir);
|
|
@@ -516,6 +531,7 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
516
531
|
version: manifest.version,
|
|
517
532
|
spec,
|
|
518
533
|
tools: toolNames,
|
|
534
|
+
...(mcp ? { mcp } : {}),
|
|
519
535
|
skills: skillIds,
|
|
520
536
|
actauthRules: ruleNames,
|
|
521
537
|
env: manifest.env ?? [],
|
|
@@ -523,8 +539,15 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
523
539
|
contentHashes,
|
|
524
540
|
};
|
|
525
541
|
writeProvenance(agentName, provenance);
|
|
542
|
+
if (mcp)
|
|
543
|
+
clearMcpSchemaCache(agentDir(agentName), manifest.name);
|
|
526
544
|
return {
|
|
527
|
-
installed: [
|
|
545
|
+
installed: [
|
|
546
|
+
...toolNames.map((n) => `tools/${n}.ts`),
|
|
547
|
+
...(mcp?.tools.map((t) => `mcp:${t.name}`) ?? []),
|
|
548
|
+
...skillIds.map((id) => `skills/${id}/`),
|
|
549
|
+
...ruleNames.map((n) => `actauth:${n}`),
|
|
550
|
+
],
|
|
528
551
|
dependencies: manifest.dependencies ?? [],
|
|
529
552
|
};
|
|
530
553
|
}
|
|
@@ -619,6 +642,7 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
619
642
|
checkLoopengineVersion(newManifest, options.installedLoopengineRange);
|
|
620
643
|
const results = [];
|
|
621
644
|
const newContentHashes = { ...record.contentHashes };
|
|
645
|
+
const remainingTools = [];
|
|
622
646
|
for (const toolName of record.tools) {
|
|
623
647
|
const relPath = `tools/${toolName}.ts`;
|
|
624
648
|
// matchesInstalledToolName, not a bare basename(f, '.ts') === toolName
|
|
@@ -627,7 +651,23 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
627
651
|
// off textually the way a skill id's "/" is.
|
|
628
652
|
const oldFile = (oldManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
629
653
|
const newFile = (newManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
630
|
-
|
|
654
|
+
// The new version no longer has this tool file (its tools may now come
|
|
655
|
+
// from an MCP server): an unedited copy goes, an edited one stays.
|
|
656
|
+
if (!newFile) {
|
|
657
|
+
const minePath = join(agentDir(agentName), relPath);
|
|
658
|
+
if (isDirty(minePath, record.contentHashes[relPath])) {
|
|
659
|
+
remainingTools.push(toolName);
|
|
660
|
+
results.push({ path: relPath, status: 'conflict' });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
rmSync(minePath, { force: true });
|
|
664
|
+
removeToolFromIndex(join(agentDir(agentName), 'tools', 'index.ts'), toolName, toCamelCase(toolName));
|
|
665
|
+
delete newContentHashes[relPath];
|
|
666
|
+
results.push({ path: relPath, status: 'removed' });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
remainingTools.push(toolName);
|
|
670
|
+
if (!oldFile) {
|
|
631
671
|
results.push({ path: relPath, status: 'unchanged' });
|
|
632
672
|
continue;
|
|
633
673
|
}
|
|
@@ -699,8 +739,34 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
699
739
|
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
700
740
|
results.push({ path, status: 'updated' });
|
|
701
741
|
}
|
|
742
|
+
// Rules the new version adds (for tools it adds) are installed, unless
|
|
743
|
+
// the agent already has a rule for that tool.
|
|
744
|
+
const actauthRules = [...record.actauthRules];
|
|
745
|
+
for (const rule of newRules) {
|
|
746
|
+
if (record.actauthRules.some((name) => matchesInstalledRuleName(abilityName, rule.name, name)))
|
|
747
|
+
continue;
|
|
748
|
+
const rules = readActauthConfig(agentName).rules;
|
|
749
|
+
if (rules.some((r) => r.tool === rule.tool))
|
|
750
|
+
continue;
|
|
751
|
+
const taken = new Set(rules.map((r) => r.name));
|
|
752
|
+
const name = taken.has(rule.name) ? namespacedRuleName(abilityName, rule.name) : rule.name;
|
|
753
|
+
if (taken.has(name))
|
|
754
|
+
continue;
|
|
755
|
+
addActauthRule(agentName, { ...rule, name });
|
|
756
|
+
actauthRules.push(name);
|
|
757
|
+
results.push({ path: `actauth:${name}`, status: 'added' });
|
|
758
|
+
}
|
|
759
|
+
// An MCP ability's tools are data, not files, so there's nothing to
|
|
760
|
+
// merge: they take the new version's server and tool list as-is.
|
|
761
|
+
const { mcp: _oldMcp, ...rest } = record;
|
|
762
|
+
const newMcp = newManifest.mcp === undefined ? undefined : resolveMcp(newManifest);
|
|
763
|
+
for (const tool of newMcp?.tools ?? [])
|
|
764
|
+
results.push({ path: `mcp:${tool.name}`, status: record.mcp?.tools.some((t) => t.name === tool.name && t.remote === tool.remote) ? 'unchanged' : 'updated' });
|
|
702
765
|
provenance[abilityName] = {
|
|
703
|
-
...
|
|
766
|
+
...rest,
|
|
767
|
+
...(newMcp ? { mcp: newMcp } : {}),
|
|
768
|
+
tools: remainingTools,
|
|
769
|
+
actauthRules,
|
|
704
770
|
version: newManifest.version,
|
|
705
771
|
spec,
|
|
706
772
|
contentHashes: newContentHashes,
|
|
@@ -708,8 +774,69 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
708
774
|
dependencies: newManifest.dependencies ?? record.dependencies,
|
|
709
775
|
};
|
|
710
776
|
writeProvenance(agentName, provenance);
|
|
777
|
+
if (record.mcp || newMcp)
|
|
778
|
+
clearMcpSchemaCache(agentDir(agentName), abilityName);
|
|
711
779
|
return { files: results };
|
|
712
780
|
}
|
|
781
|
+
// ---- An agent's MCP tools ----
|
|
782
|
+
/** Sets which of an MCP ability's server tools an agent has, and each
|
|
783
|
+
* one's permission for that agent. `abilityDir` is the ability's folder
|
|
784
|
+
* (an app's workspace copy), which lists every tool. A tool's permission
|
|
785
|
+
* is its first rule in the agent's actauth.yml (an agent's own rule for a
|
|
786
|
+
* tool is changed rather than shadowed); a tool with no rule gets the
|
|
787
|
+
* ability's. Rules the ability added for tools taken away go with them. */
|
|
788
|
+
export function configureAgentMcpTools(agentName, abilityName, abilityDir, choice) {
|
|
789
|
+
const provenance = readProvenance(agentName);
|
|
790
|
+
const record = provenance[abilityName];
|
|
791
|
+
if (!record?.mcp)
|
|
792
|
+
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed as an MCP ability for '${agentName}'.`);
|
|
793
|
+
const manifest = readManifest(abilityDir);
|
|
794
|
+
const all = resolveMcp(manifest);
|
|
795
|
+
const unknown = choice.filter((c) => !all.tools.some((t) => t.remote === c.remote)).map((c) => c.remote);
|
|
796
|
+
if (unknown.length)
|
|
797
|
+
throw new AbilityManifestError(`${abilityName} has no tool named ${unknown.join(', ')}.`);
|
|
798
|
+
const tools = all.tools.filter((t) => choice.some((c) => c.remote === t.remote));
|
|
799
|
+
if (!tools.length)
|
|
800
|
+
throw new AbilityManifestError(`Choose at least one of ${abilityName}'s tools, or remove it from the agent instead.`);
|
|
801
|
+
const before = new Set(record.mcp.tools.map((t) => t.name));
|
|
802
|
+
const after = new Set(tools.map((t) => t.name));
|
|
803
|
+
const added = [...after].filter((t) => !before.has(t));
|
|
804
|
+
const removed = [...before].filter((t) => !after.has(t));
|
|
805
|
+
const changed = [];
|
|
806
|
+
const ruleNames = [...record.actauthRules];
|
|
807
|
+
for (const name of record.actauthRules) {
|
|
808
|
+
const rule = readActauthConfig(agentName).rules.find((r) => r.name === name);
|
|
809
|
+
if (rule && removed.includes(rule.tool)) {
|
|
810
|
+
try {
|
|
811
|
+
removeActauthRule(agentName, name);
|
|
812
|
+
}
|
|
813
|
+
catch { }
|
|
814
|
+
ruleNames.splice(ruleNames.indexOf(name), 1);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
for (const tool of tools) {
|
|
818
|
+
const decision = choice.find((c) => c.remote === tool.remote).decision;
|
|
819
|
+
const current = readActauthConfig(agentName).rules;
|
|
820
|
+
const rule = current.find((r) => r.tool === tool.name);
|
|
821
|
+
if (rule) {
|
|
822
|
+
if (rule.decision !== decision) {
|
|
823
|
+
updateActauthRule(agentName, rule.name, { scope: rule.scope, tool: rule.tool, decision });
|
|
824
|
+
changed.push(tool.name);
|
|
825
|
+
}
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const taken = new Set(current.map((r) => r.name));
|
|
829
|
+
let name = appRuleName(abilityName, tool.name);
|
|
830
|
+
for (let i = 2; taken.has(name); i++)
|
|
831
|
+
name = `${appRuleName(abilityName, tool.name)}-${i}`;
|
|
832
|
+
addActauthRule(agentName, { name, scope: '*/*', tool: tool.name, decision });
|
|
833
|
+
ruleNames.push(name);
|
|
834
|
+
}
|
|
835
|
+
provenance[abilityName] = { ...record, mcp: { ...all, tools }, version: manifest.version, actauthRules: ruleNames };
|
|
836
|
+
writeProvenance(agentName, provenance);
|
|
837
|
+
clearMcpSchemaCache(agentDir(agentName), abilityName);
|
|
838
|
+
return { added, removed, changed };
|
|
839
|
+
}
|
|
713
840
|
// ---- Remove ----
|
|
714
841
|
function isDirty(path, recordedHash) {
|
|
715
842
|
if (!existsSync(path))
|
|
@@ -792,12 +919,18 @@ export function removeAbility(agentName, abilityName, force = false) {
|
|
|
792
919
|
// Already gone (hand-removed since install) — not a failure.
|
|
793
920
|
}
|
|
794
921
|
}
|
|
922
|
+
// An MCP ability's tools have no files to be dirty, so they always go.
|
|
923
|
+
for (const tool of record.mcp?.tools ?? [])
|
|
924
|
+
removed.push(`mcp:${tool.name}`);
|
|
795
925
|
if (refused.length === 0) {
|
|
796
926
|
delete provenance[abilityName];
|
|
797
927
|
}
|
|
798
928
|
else {
|
|
799
|
-
|
|
929
|
+
const { mcp: _mcp, ...rest } = record;
|
|
930
|
+
provenance[abilityName] = { ...rest, tools: remainingTools, skills: remainingSkills, actauthRules: [] };
|
|
800
931
|
}
|
|
801
932
|
writeProvenance(agentName, provenance);
|
|
933
|
+
if (record.mcp)
|
|
934
|
+
clearMcpSchemaCache(agentDir(agentName), abilityName);
|
|
802
935
|
return { removed, refused };
|
|
803
936
|
}
|
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
8
|
import ts from 'typescript';
|
|
9
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
import { resolveMcpManifest } from './mcp-abilities.js';
|
|
10
11
|
/** Throws when `dir` holds no loopengine.ability.json. */
|
|
11
12
|
export function readAbilityDetails(dir) {
|
|
12
13
|
const manifestPath = join(dir, 'loopengine.ability.json');
|
|
@@ -26,6 +27,16 @@ export function readAbilityDetails(dir) {
|
|
|
26
27
|
const listed = found.length ? found : [{ name: basename(file).replace(/\.[cm]?[jt]s$/, ''), description: '' }];
|
|
27
28
|
return listed.map((tool) => (decisions[tool.name] ? { ...tool, decision: decisions[tool.name] } : tool));
|
|
28
29
|
});
|
|
30
|
+
// An MCP ability's tools are named from its allowlist; descriptions
|
|
31
|
+
// come from the server, so they're unknown until it's connected.
|
|
32
|
+
if (manifest.mcp !== undefined) {
|
|
33
|
+
try {
|
|
34
|
+
for (const tool of resolveMcpManifest(pkg.name || basename(dir), manifest.mcp).tools) {
|
|
35
|
+
tools.push(decisions[tool.name] ? { name: tool.name, description: '', decision: decisions[tool.name] } : { name: tool.name, description: '' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
29
40
|
const skills = strings(manifest.skills).map((skillDir) => readSkill(join(dir, skillDir), skillDir));
|
|
30
41
|
return { name: pkg.name || basename(dir), version: pkg.version || '', description: pkg.description || '', tools, skills, settings: readSettings(manifest) };
|
|
31
42
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AgentEnv, ToolDefinition } from './agent-config.js';
|
|
2
|
+
/** How the adapter authenticates to a remote server. `key` sends an
|
|
3
|
+
* agent setting as a header (`Authorization: Bearer <value>` by
|
|
4
|
+
* default). OAuth isn't supported yet. */
|
|
5
|
+
export type McpAuth = {
|
|
6
|
+
type: 'none';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'key';
|
|
9
|
+
env: string;
|
|
10
|
+
header?: string;
|
|
11
|
+
prefix?: string;
|
|
12
|
+
};
|
|
13
|
+
/** The `mcp` block of loopengine.ability.json. Exactly one of `url`
|
|
14
|
+
* (a remote server) or `command` (a local server, started as a child
|
|
15
|
+
* process) is set. String values in `headers`, `args` and `env` may use
|
|
16
|
+
* `${NAME}` to read an agent setting. */
|
|
17
|
+
export interface McpServerManifest {
|
|
18
|
+
url?: string;
|
|
19
|
+
/** `streamable-http` (the default) or the older `sse` transport. */
|
|
20
|
+
transport?: 'streamable-http' | 'sse';
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
auth?: McpAuth;
|
|
23
|
+
command?: string;
|
|
24
|
+
args?: string[];
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
/** The server's tool names to expose — nothing else on the server is. */
|
|
27
|
+
tools: string[];
|
|
28
|
+
/** Prefix for the installed tool names (`<prefix>_<tool>`). Defaults to
|
|
29
|
+
* the ability's name without `lp-`. */
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/** Longest tool result passed back to the model, in characters. */
|
|
32
|
+
maxOutputChars?: number;
|
|
33
|
+
/** Tool inputs filled from the agent's settings when a call leaves them
|
|
34
|
+
* out, e.g. a default repository: `{ "repo": "${GITHUB_REPO}" }`, or a
|
|
35
|
+
* part of one setting, `{ "owner": { "env": "GITHUB_DEFAULT_REPO",
|
|
36
|
+
* "split": "/", "part": 0 } }`. An input with a default is no longer
|
|
37
|
+
* required in the schema the model sees, which says what it defaults to. */
|
|
38
|
+
defaults?: Record<string, McpDefault>;
|
|
39
|
+
}
|
|
40
|
+
export type McpDefault = string | {
|
|
41
|
+
env: string;
|
|
42
|
+
split?: string;
|
|
43
|
+
part?: number;
|
|
44
|
+
};
|
|
45
|
+
/** What add-ability records for an MCP ability: the server block, and
|
|
46
|
+
* each installed tool name with the server tool it calls. */
|
|
47
|
+
export interface InstalledMcpRecord {
|
|
48
|
+
server: Omit<McpServerManifest, 'tools' | 'prefix'>;
|
|
49
|
+
tools: Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
remote: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export declare class McpManifestError extends Error {
|
|
55
|
+
}
|
|
56
|
+
/** Checks an ability's `mcp` block and works out its installed tool
|
|
57
|
+
* names. Throws McpManifestError on anything unusable, so a bad
|
|
58
|
+
* manifest fails at add-ability, not on the agent's first run. */
|
|
59
|
+
export declare function resolveMcpManifest(abilityName: string, mcp: unknown): InstalledMcpRecord;
|
|
60
|
+
/** An installed tool's name: `<prefix>_<tool>` in snake_case, or just the
|
|
61
|
+
* tool's own name when it already carries the prefix as a word
|
|
62
|
+
* (`web_search_exa` stays as is for prefix `exa`). */
|
|
63
|
+
export declare function mcpToolName(prefix: string, remote: string): string;
|
|
64
|
+
/** Closes every open server connection (and stops local servers). */
|
|
65
|
+
export declare function closeMcpConnections(): Promise<void>;
|
|
66
|
+
/** Whether a connection failed because the server wants (other)
|
|
67
|
+
* credentials: an HTTP 401/403, or the SDK's own UnauthorizedError. */
|
|
68
|
+
export declare function isMcpAuthError(err: unknown): boolean;
|
|
69
|
+
/** A server tool as listed, for choosing tools before an ability exists. */
|
|
70
|
+
export interface McpToolInfo {
|
|
71
|
+
name: string;
|
|
72
|
+
description: string;
|
|
73
|
+
inputSchema: Record<string, unknown>;
|
|
74
|
+
/** The server's own hints (MCP tool annotations), when it gives them. */
|
|
75
|
+
readOnly?: boolean;
|
|
76
|
+
destructive?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** Every tool a server offers, over a one-off connection (closed after).
|
|
79
|
+
* `server` is an `mcp` block without its allowlist; settings in it are
|
|
80
|
+
* read from `env`. */
|
|
81
|
+
export declare function listMcpServerTools(server: InstalledMcpRecord['server'], env: AgentEnv): Promise<McpToolInfo[]>;
|
|
82
|
+
/** Drops an ability's cached schemas — add-ability and upgrade-ability
|
|
83
|
+
* call this so a new version's tools are fetched fresh. */
|
|
84
|
+
export declare function clearMcpSchemaCache(dir: string, abilityName: string): void;
|
|
85
|
+
export declare function mcpAbilityLoadError(dir: string, abilityName: string): string | undefined;
|
|
86
|
+
/** Every tool of every MCP ability installed in `dir` (an agent's
|
|
87
|
+
* folder). An ability whose schemas aren't cached yet is connected to
|
|
88
|
+
* once to fetch them; if that fails (no key yet, server down) its tools
|
|
89
|
+
* are left out and the reason is kept for mcpAbilityLoadError, rather
|
|
90
|
+
* than failing the whole agent. */
|
|
91
|
+
export declare function loadMcpAbilityTools(dir: string, agentName: string): Promise<ToolDefinition[]>;
|