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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type McpDirectoryServer } from './mcp-directory.js';
|
|
2
|
+
import type { WorkspaceAbility } from './workspace-abilities.js';
|
|
3
|
+
export interface AppCatalogEntry {
|
|
4
|
+
/** Its MCP Registry name. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The app's own website, for its icon when the registry has none. */
|
|
7
|
+
site: string;
|
|
8
|
+
title: string;
|
|
9
|
+
url: string;
|
|
10
|
+
category: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** Headers the server needs to offer the right tools (e.g. GitHub's
|
|
13
|
+
* toolsets). */
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export declare const APP_CATALOG: AppCatalogEntry[];
|
|
17
|
+
/** A catalog entry as a directory server (what the generator takes). */
|
|
18
|
+
export declare function catalogServer(entry: AppCatalogEntry): McpDirectoryServer;
|
|
19
|
+
/** The server for an app id: from the catalog, else the directory
|
|
20
|
+
* (saved servers, then the registry). */
|
|
21
|
+
export declare function appServer(id: string): Promise<McpDirectoryServer | undefined>;
|
|
22
|
+
/** Starts fetching the catalog's icons (once); resolves when done. */
|
|
23
|
+
export declare function loadAppIcons(): Promise<void>;
|
|
24
|
+
export interface ConnectedApp {
|
|
25
|
+
/** The registry name or saved id it was made from, when recorded. */
|
|
26
|
+
id?: string;
|
|
27
|
+
title: string;
|
|
28
|
+
url: string;
|
|
29
|
+
/** The workspace ability it is (e.g. mcp-linear) and its spec. */
|
|
30
|
+
ability: string;
|
|
31
|
+
spec: string;
|
|
32
|
+
/** Its tools, with the permission each was set up with. */
|
|
33
|
+
tools: {
|
|
34
|
+
name: string;
|
|
35
|
+
decision?: 'allow' | 'ask' | 'deny';
|
|
36
|
+
}[];
|
|
37
|
+
/** The setting its key is read from, and whether it has a value. */
|
|
38
|
+
keyEnv?: string;
|
|
39
|
+
keySet: boolean;
|
|
40
|
+
/** Agents that have added it. */
|
|
41
|
+
agents: string[];
|
|
42
|
+
connectedAt: string;
|
|
43
|
+
}
|
|
44
|
+
export interface AppView extends AppCatalogEntry {
|
|
45
|
+
icon?: string;
|
|
46
|
+
connected: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Whether a workspace ability takes its tools from an MCP server (a
|
|
49
|
+
* connected app). */
|
|
50
|
+
export declare function isAppAbility(a: Pick<WorkspaceAbility, 'spec'>): boolean;
|
|
51
|
+
/** The workspace abilities that take their tools from an MCP server, as
|
|
52
|
+
* connected apps. */
|
|
53
|
+
export declare function connectedApps(workspace: WorkspaceAbility[], agentAbilities: Record<string, string[]>, envValue: (name: string) => string | undefined): ConnectedApp[];
|
|
54
|
+
/** The catalog, each app marked connected or not, with its icon once known. */
|
|
55
|
+
export declare function appCatalog(connected: ConnectedApp[]): AppView[];
|
|
56
|
+
export declare class AppToolsError extends Error {
|
|
57
|
+
}
|
|
58
|
+
export interface AppTool {
|
|
59
|
+
/** The server's name for it, and the name agents see. */
|
|
60
|
+
remote: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description: string;
|
|
63
|
+
readOnly?: boolean;
|
|
64
|
+
destructive?: boolean;
|
|
65
|
+
/** Its default permission. */
|
|
66
|
+
decision: 'allow' | 'ask' | 'deny';
|
|
67
|
+
/** Agents that have it, with the permission each gave it. */
|
|
68
|
+
usedBy: {
|
|
69
|
+
agent: string;
|
|
70
|
+
decision: 'allow' | 'ask' | 'deny';
|
|
71
|
+
}[];
|
|
72
|
+
}
|
|
73
|
+
/** An agent's tools from one app: its chosen server tools, each with its
|
|
74
|
+
* permission there (the agent's first rule for the tool). */
|
|
75
|
+
export type AgentAppTools = Record<string, {
|
|
76
|
+
remote: string;
|
|
77
|
+
decision: 'allow' | 'ask' | 'deny';
|
|
78
|
+
}[]>;
|
|
79
|
+
/** Every tool of the app, with its default permission and the agents
|
|
80
|
+
* using it. Tools the server added since connecting are listed too, at
|
|
81
|
+
* their default (see refreshAppTools to add them for good). */
|
|
82
|
+
export declare function appTools(spec: string, envValue: (name: string) => string | undefined, agents: AgentAppTools, options?: {
|
|
83
|
+
fresh?: boolean;
|
|
84
|
+
}): Promise<AppTool[]>;
|
|
85
|
+
/** Sets a tool's default permission, for agents that add it from now on;
|
|
86
|
+
* agents that already have it keep theirs. */
|
|
87
|
+
export declare function setAppDefault(spec: string, remote: string, decision: 'allow' | 'ask' | 'deny'): void;
|
|
88
|
+
/** Brings the app's tool list up to date with its server: new tools are
|
|
89
|
+
* added at their default permission, ones it no longer offers dropped.
|
|
90
|
+
* Agents keep the tools they chose (one the server dropped just stops
|
|
91
|
+
* loading). Returns what changed. */
|
|
92
|
+
export declare function refreshAppTools(spec: string, envValue: (name: string) => string | undefined): Promise<{
|
|
93
|
+
added: string[];
|
|
94
|
+
removed: string[];
|
|
95
|
+
}>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The workspace's Apps (the console's Apps page): popular apps that
|
|
2
|
+
// publish their own remote MCP server, and the ones this workspace has
|
|
3
|
+
// connected. Connecting one makes a private ability from its server
|
|
4
|
+
// (core/mcp-ability-generator.ts, with no workflow to fit, so it picks
|
|
5
|
+
// the tools most useful day to day) and stores it as a workspace ability
|
|
6
|
+
// with source `mcp:<url>`; agents then add it from their Apps tab.
|
|
7
|
+
//
|
|
8
|
+
// Every entry below is the app's own server as listed in the official MCP
|
|
9
|
+
// Registry (checked November 2026). The URL is kept here so the page works
|
|
10
|
+
// without reaching the registry; the registry only adds icons.
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
14
|
+
import { getMcpServer } from './mcp-directory.js';
|
|
15
|
+
import { readAbilityDetails } from './ability-details.js';
|
|
16
|
+
import { appRuleName, defaultMcpDecision, mcpAbilityNames } from './mcp-ability-generator.js';
|
|
17
|
+
import { listMcpServerTools, mcpToolName } from './mcp-abilities.js';
|
|
18
|
+
export const APP_CATALOG = [
|
|
19
|
+
{ id: 'com.stripe/mcp', site: 'stripe.com', title: 'Stripe', url: 'https://mcp.stripe.com', category: 'Payments', description: 'Customers, payments, invoices, subscriptions and refunds.' },
|
|
20
|
+
{ id: 'com.paypal.mcp/mcp', site: 'paypal.com', title: 'PayPal', url: 'https://mcp.paypal.com/mcp', category: 'Payments', description: 'Invoices, orders, payments and disputes.' },
|
|
21
|
+
{ id: 'com.notion/mcp', site: 'notion.com', title: 'Notion', url: 'https://mcp.notion.com/mcp', category: 'Docs', description: 'Search, read and write pages and databases.' },
|
|
22
|
+
{ id: 'com.atlassian/atlassian-mcp-server', site: 'atlassian.com', title: 'Jira & Confluence', url: 'https://mcp.atlassian.com/v2/mcp', category: 'Projects', description: 'Jira issues and Confluence pages (Atlassian).' },
|
|
23
|
+
{ id: 'app.linear/linear', site: 'linear.app', title: 'Linear', url: 'https://mcp.linear.app/mcp', category: 'Projects', description: 'Issues, projects and documents.' },
|
|
24
|
+
{ id: 'com.monday/monday.com', site: 'monday.com', title: 'monday.com', url: 'https://mcp.monday.com/mcp', category: 'Projects', description: 'Boards, items and updates.' },
|
|
25
|
+
{ id: 'com.basecamp/mcp', site: 'basecamp.com', title: 'Basecamp', url: 'https://mcp.basecamp.com/mcp', category: 'Projects', description: 'Projects, to-dos and messages.' },
|
|
26
|
+
{ id: 'net.todoist/mcp', site: 'todoist.com', title: 'Todoist', url: 'https://ai.todoist.net/mcp', category: 'Projects', description: 'Tasks and projects.' },
|
|
27
|
+
{ id: 'com.airtable/mcp', site: 'airtable.com', title: 'Airtable', url: 'https://mcp.airtable.com/mcp', category: 'Data', description: 'Bases, tables and records.' },
|
|
28
|
+
{ id: 'io.github.github/github-mcp-server', site: 'github.com', title: 'GitHub', url: 'https://api.githubcopilot.com/mcp/', category: 'Engineering', description: 'Repositories, issues, pull requests and Actions runs.', headers: { 'X-MCP-Toolsets': 'repos,issues,pull_requests,actions' } },
|
|
29
|
+
{ id: 'com.gitlab/mcp', site: 'gitlab.com', title: 'GitLab', url: 'https://gitlab.com/api/v4/mcp', category: 'Engineering', description: 'Projects, issues and merge requests.' },
|
|
30
|
+
{ id: 'io.github.getsentry/sentry-mcp', site: 'sentry.io', title: 'Sentry', url: 'https://mcp.sentry.dev/mcp', category: 'Engineering', description: 'Errors, issues and releases.' },
|
|
31
|
+
{ id: 'com.vercel/vercel-mcp', site: 'vercel.com', title: 'Vercel', url: 'https://mcp.vercel.com', category: 'Engineering', description: 'Projects, deployments and logs.' },
|
|
32
|
+
{ id: 'com.supabase/mcp', site: 'supabase.com', title: 'Supabase', url: 'https://mcp.supabase.com/mcp', category: 'Engineering', description: 'Databases, tables and projects.' },
|
|
33
|
+
{ id: 'com.circleci/mcp', site: 'circleci.com', title: 'CircleCI', url: 'https://mcp.circleci.com/v1/mcp', category: 'Engineering', description: 'Pipelines, builds and test results.' },
|
|
34
|
+
{ id: 'io.github.PostHog/mcp', site: 'posthog.com', title: 'PostHog', url: 'https://mcp.posthog.com/mcp', category: 'Analytics', description: 'Product analytics, insights and feature flags.' },
|
|
35
|
+
{ id: 'com.amplitude/mcp-server', site: 'amplitude.com', title: 'Amplitude', url: 'https://mcp.amplitude.com/mcp', category: 'Analytics', description: 'Charts, dashboards and events.' },
|
|
36
|
+
{ id: 'com.figma.mcp/mcp', site: 'figma.com', title: 'Figma', url: 'https://mcp.figma.com/mcp', category: 'Design', description: 'Design files, frames and comments.' },
|
|
37
|
+
{ id: 'com.canva.mcp/mcp', site: 'canva.com', title: 'Canva', url: 'https://mcp.canva.com/mcp', category: 'Design', description: 'Designs, templates and exports.' },
|
|
38
|
+
{ id: 'io.github.miroapp/mcp-server', site: 'miro.com', title: 'Miro', url: 'https://mcp.miro.com/', category: 'Design', description: 'Boards, sticky notes and diagrams.' },
|
|
39
|
+
{ id: 'com.webflow/mcp', site: 'webflow.com', title: 'Webflow', url: 'https://mcp.webflow.com/mcp', category: 'Websites', description: 'Sites, pages and CMS items.' },
|
|
40
|
+
{ id: 'com.wix/mcp', site: 'wix.com', title: 'Wix', url: 'https://mcp.wix.com/mcp', category: 'Websites', description: 'Sites, stores and bookings.' },
|
|
41
|
+
{ id: 'com.typeform/typeform', site: 'typeform.com', title: 'Typeform', url: 'https://api.typeform.com/mcp', category: 'Forms', description: 'Forms and responses.' },
|
|
42
|
+
{ id: 'com.jotform/mcp', site: 'jotform.com', title: 'Jotform', url: 'https://mcp.jotform.com/', category: 'Forms', description: 'Forms and submissions.' },
|
|
43
|
+
{ id: 'com.zapier/mcp', site: 'zapier.com', title: 'Zapier', url: 'https://mcp.zapier.com/api/v1/connect', category: 'Automation', description: 'Actions across thousands of apps, through your Zapier account.' },
|
|
44
|
+
{ id: 'com.apify/apify-mcp-server', site: 'apify.com', title: 'Apify', url: 'https://mcp.apify.com/', category: 'Web data', description: 'Scrapers and crawlers (Apify Actors).' },
|
|
45
|
+
{ id: 'io.github.firecrawl/firecrawl-mcp-server', site: 'firecrawl.dev', title: 'Firecrawl', url: 'https://mcp.firecrawl.dev/v2/mcp', category: 'Web data', description: 'Search the web, read pages and files, map and crawl sites. Add an API key for crawling, mapping and monitors.' },
|
|
46
|
+
{ id: 'ai.exa/exa', site: 'exa.ai', title: 'Exa', url: 'https://mcp.exa.ai/mcp', category: 'Web data', description: 'Web search built for AI.' },
|
|
47
|
+
{ id: 'co.huggingface/hf-mcp-server', site: 'huggingface.co', title: 'Hugging Face', url: 'https://huggingface.co/mcp?login', category: 'AI', description: 'Models, datasets and Spaces.' },
|
|
48
|
+
];
|
|
49
|
+
/** A catalog entry as a directory server (what the generator takes). */
|
|
50
|
+
export function catalogServer(entry) {
|
|
51
|
+
return { id: entry.id, title: entry.title, description: entry.description, url: entry.url, transport: 'streamable-http', source: 'registry', verified: true, ...(entry.headers ? { headers: entry.headers } : {}) };
|
|
52
|
+
}
|
|
53
|
+
/** The server for an app id: from the catalog, else the directory
|
|
54
|
+
* (saved servers, then the registry). */
|
|
55
|
+
export async function appServer(id) {
|
|
56
|
+
const entry = APP_CATALOG.find((a) => a.id === id);
|
|
57
|
+
return entry ? catalogServer(entry) : getMcpServer(id);
|
|
58
|
+
}
|
|
59
|
+
// Icons come from the registry, fetched once in the background; an app
|
|
60
|
+
// without one there gets its own site's favicon.
|
|
61
|
+
const icons = new Map();
|
|
62
|
+
let iconsLoading;
|
|
63
|
+
/** Starts fetching the catalog's icons (once); resolves when done. */
|
|
64
|
+
export function loadAppIcons() {
|
|
65
|
+
iconsLoading ??= (async () => {
|
|
66
|
+
for (let i = 0; i < APP_CATALOG.length; i += 6) {
|
|
67
|
+
await Promise.all(APP_CATALOG.slice(i, i + 6).map(async (entry) => {
|
|
68
|
+
try {
|
|
69
|
+
icons.set(entry.id, (await getMcpServer(entry.id))?.icon ?? null);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
icons.set(entry.id, null);
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
return iconsLoading;
|
|
78
|
+
}
|
|
79
|
+
/** Whether a workspace ability takes its tools from an MCP server (a
|
|
80
|
+
* connected app). */
|
|
81
|
+
export function isAppAbility(a) {
|
|
82
|
+
return Boolean(readManifestAt(a.spec.replace(/^file:/, '')).mcp?.url);
|
|
83
|
+
}
|
|
84
|
+
function readManifestAt(dir) {
|
|
85
|
+
try {
|
|
86
|
+
if (existsSync(join(dir, 'loopengine.ability.json')))
|
|
87
|
+
return JSON.parse(readFileSync(join(dir, 'loopengine.ability.json'), 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
/** The workspace abilities that take their tools from an MCP server, as
|
|
93
|
+
* connected apps. */
|
|
94
|
+
export function connectedApps(workspace, agentAbilities, envValue) {
|
|
95
|
+
return workspace.flatMap((a) => {
|
|
96
|
+
const dir = a.spec.replace(/^file:/, '');
|
|
97
|
+
const manifest = readManifestAt(dir);
|
|
98
|
+
const url = manifest.mcp?.url;
|
|
99
|
+
if (!url)
|
|
100
|
+
return [];
|
|
101
|
+
let tools = [];
|
|
102
|
+
try {
|
|
103
|
+
tools = readAbilityDetails(dir).tools.map((t) => ({ name: t.name, ...(t.decision ? { decision: t.decision } : {}) }));
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
const entry = APP_CATALOG.find((e) => e.url === url || e.id === manifest.generatedFrom?.server);
|
|
107
|
+
const keyEnv = manifest.mcp?.auth?.type === 'key' ? manifest.mcp.auth.env : undefined;
|
|
108
|
+
return [{
|
|
109
|
+
...(manifest.generatedFrom?.server ?? entry?.id ? { id: manifest.generatedFrom?.server ?? entry.id } : {}),
|
|
110
|
+
title: entry?.title || a.description.split(':')[0].replace(/ \(made from its MCP server\)$/, '') || a.name,
|
|
111
|
+
url,
|
|
112
|
+
ability: a.name,
|
|
113
|
+
spec: a.spec,
|
|
114
|
+
tools,
|
|
115
|
+
...(keyEnv ? { keyEnv } : {}),
|
|
116
|
+
keySet: keyEnv ? envValue(keyEnv) !== undefined : true,
|
|
117
|
+
agents: Object.entries(agentAbilities).filter(([, names]) => names.includes(a.name)).map(([agent]) => agent),
|
|
118
|
+
connectedAt: a.installedAt,
|
|
119
|
+
}];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** The catalog, each app marked connected or not, with its icon once known. */
|
|
123
|
+
export function appCatalog(connected) {
|
|
124
|
+
return APP_CATALOG.map((entry) => {
|
|
125
|
+
const icon = icons.get(entry.id);
|
|
126
|
+
return {
|
|
127
|
+
...entry,
|
|
128
|
+
icon: icon || `https://${entry.site}/favicon.ico`,
|
|
129
|
+
connected: connected.some((c) => c.url === entry.url || c.id === entry.id || c.ability === mcpAbilityNames(entry).name),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// ---- An app's tools ----
|
|
134
|
+
// A connected app lists every tool its server offers, each with a
|
|
135
|
+
// default permission (Allow when it only reads, else Ask me). Agents
|
|
136
|
+
// choose which of them they get, and with what permission, when they add
|
|
137
|
+
// the app; the defaults are what an agent starts from.
|
|
138
|
+
export class AppToolsError extends Error {
|
|
139
|
+
}
|
|
140
|
+
function readApp(dir) {
|
|
141
|
+
const manifestPath = join(dir, 'loopengine.ability.json');
|
|
142
|
+
if (!existsSync(manifestPath))
|
|
143
|
+
throw new AppToolsError('That app isn\'t in the workspace any more.');
|
|
144
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
145
|
+
if (!manifest.mcp || !Array.isArray(manifest.mcp.tools))
|
|
146
|
+
throw new AppToolsError('That ability doesn\'t come from an MCP server.');
|
|
147
|
+
const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name;
|
|
148
|
+
const rulesPath = join(dir, manifest.actauth ?? 'actauth/rules.yml');
|
|
149
|
+
const rules = existsSync(rulesPath) ? (parseYaml(readFileSync(rulesPath, 'utf8')) ?? []) : [];
|
|
150
|
+
return { name, manifest, rules, rulesPath };
|
|
151
|
+
}
|
|
152
|
+
function prefixOf(name, manifest) {
|
|
153
|
+
return manifest.mcp.prefix ?? name.replace(/^@[^/]+\//, '').replace(/^(lp|mcp)-/, '');
|
|
154
|
+
}
|
|
155
|
+
function envFrom(value) {
|
|
156
|
+
return {
|
|
157
|
+
get: value,
|
|
158
|
+
require(name) {
|
|
159
|
+
const v = value(name);
|
|
160
|
+
if (v === undefined)
|
|
161
|
+
throw new AppToolsError(`${name} isn't set, so the app's tools can't be listed.`);
|
|
162
|
+
return v;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// The server's list, kept a few minutes so opening the list again
|
|
167
|
+
// doesn't reconnect each time.
|
|
168
|
+
const listings = new Map();
|
|
169
|
+
const LISTING_MS = 5 * 60 * 1000;
|
|
170
|
+
async function serverTools(dir, manifest, envValue, fresh = false) {
|
|
171
|
+
const hit = listings.get(dir);
|
|
172
|
+
if (!fresh && hit && Date.now() - hit.at < LISTING_MS)
|
|
173
|
+
return hit.tools;
|
|
174
|
+
const { tools: _tools, prefix: _prefix, ...server } = manifest.mcp;
|
|
175
|
+
const tools = await listMcpServerTools(server, envFrom(envValue));
|
|
176
|
+
listings.set(dir, { at: Date.now(), tools });
|
|
177
|
+
return tools;
|
|
178
|
+
}
|
|
179
|
+
/** Every tool of the app, with its default permission and the agents
|
|
180
|
+
* using it. Tools the server added since connecting are listed too, at
|
|
181
|
+
* their default (see refreshAppTools to add them for good). */
|
|
182
|
+
export async function appTools(spec, envValue, agents, options = {}) {
|
|
183
|
+
const dir = spec.replace(/^file:/, '');
|
|
184
|
+
const { name, manifest, rules } = readApp(dir);
|
|
185
|
+
const prefix = prefixOf(name, manifest);
|
|
186
|
+
let listed = [];
|
|
187
|
+
try {
|
|
188
|
+
listed = await serverTools(dir, manifest, envValue, options.fresh);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Unreachable for now: still list what the app had, without details.
|
|
192
|
+
}
|
|
193
|
+
const byRemote = new Map(listed.map((t) => [t.name, t]));
|
|
194
|
+
const remotes = [...new Set([...manifest.mcp.tools, ...listed.map((t) => t.name)])];
|
|
195
|
+
return remotes.map((remote) => {
|
|
196
|
+
const info = byRemote.get(remote);
|
|
197
|
+
const installed = mcpToolName(prefix, remote);
|
|
198
|
+
const decision = rules.find((r) => r.tool === installed)?.decision ?? (info ? defaultMcpDecision(info) : 'ask');
|
|
199
|
+
return {
|
|
200
|
+
remote,
|
|
201
|
+
name: installed,
|
|
202
|
+
description: info ? info.description.split('\n')[0].slice(0, 240) : listed.length ? 'The server no longer offers this tool.' : '',
|
|
203
|
+
...(info?.readOnly !== undefined ? { readOnly: info.readOnly } : {}),
|
|
204
|
+
...(info?.destructive ? { destructive: true } : {}),
|
|
205
|
+
decision,
|
|
206
|
+
usedBy: Object.entries(agents).flatMap(([agent, tools]) => tools.filter((t) => t.remote === remote).map((t) => ({ agent, decision: t.decision }))),
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** Sets a tool's default permission, for agents that add it from now on;
|
|
211
|
+
* agents that already have it keep theirs. */
|
|
212
|
+
export function setAppDefault(spec, remote, decision) {
|
|
213
|
+
const dir = spec.replace(/^file:/, '');
|
|
214
|
+
const { name, manifest, rules, rulesPath } = readApp(dir);
|
|
215
|
+
if (!manifest.mcp.tools.includes(remote))
|
|
216
|
+
throw new AppToolsError(`${remote} isn't one of the app's tools.`);
|
|
217
|
+
const installed = mcpToolName(prefixOf(name, manifest), remote);
|
|
218
|
+
const rule = rules.find((r) => r.tool === installed);
|
|
219
|
+
if (rule)
|
|
220
|
+
rule.decision = decision;
|
|
221
|
+
else
|
|
222
|
+
rules.push({ name: appRuleName(name, installed), scope: '*/*', tool: installed, decision });
|
|
223
|
+
writeFileSync(rulesPath, stringifyYaml(rules));
|
|
224
|
+
}
|
|
225
|
+
/** Brings the app's tool list up to date with its server: new tools are
|
|
226
|
+
* added at their default permission, ones it no longer offers dropped.
|
|
227
|
+
* Agents keep the tools they chose (one the server dropped just stops
|
|
228
|
+
* loading). Returns what changed. */
|
|
229
|
+
export async function refreshAppTools(spec, envValue) {
|
|
230
|
+
const dir = spec.replace(/^file:/, '');
|
|
231
|
+
const { name, manifest, rules, rulesPath } = readApp(dir);
|
|
232
|
+
const prefix = prefixOf(name, manifest);
|
|
233
|
+
const listed = await serverTools(dir, manifest, envValue, true);
|
|
234
|
+
const taken = new Set();
|
|
235
|
+
const offered = listed.filter((t) => {
|
|
236
|
+
const installed = mcpToolName(prefix, t.name);
|
|
237
|
+
if (taken.has(installed))
|
|
238
|
+
return false;
|
|
239
|
+
taken.add(installed);
|
|
240
|
+
return true;
|
|
241
|
+
});
|
|
242
|
+
const added = offered.filter((t) => !manifest.mcp.tools.includes(t.name));
|
|
243
|
+
const removed = manifest.mcp.tools.filter((r) => !offered.some((t) => t.name === r));
|
|
244
|
+
for (const t of added) {
|
|
245
|
+
const installed = mcpToolName(prefix, t.name);
|
|
246
|
+
if (!rules.some((r) => r.tool === installed))
|
|
247
|
+
rules.push({ name: appRuleName(name, installed), scope: '*/*', tool: installed, decision: defaultMcpDecision(t) });
|
|
248
|
+
}
|
|
249
|
+
const gone = new Set(removed.map((r) => mcpToolName(prefix, r)));
|
|
250
|
+
manifest.mcp.tools = offered.map((t) => t.name);
|
|
251
|
+
writeFileSync(join(dir, 'loopengine.ability.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
252
|
+
writeFileSync(rulesPath, stringifyYaml(rules.filter((r) => !gone.has(r.tool))));
|
|
253
|
+
return { added: added.map((t) => t.name), removed };
|
|
254
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface McpDirectoryServer {
|
|
2
|
+
/** The registry name (com.stripe/mcp) or a saved server's id. */
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
url: string;
|
|
7
|
+
transport: 'streamable-http' | 'sse';
|
|
8
|
+
website?: string;
|
|
9
|
+
/** An https icon from its registry entry, when it has one. */
|
|
10
|
+
icon?: string;
|
|
11
|
+
source: 'saved' | 'registry';
|
|
12
|
+
/** Saved by the workspace, or on its publisher's own verified domain. */
|
|
13
|
+
verified: boolean;
|
|
14
|
+
/** Verified, and the publisher is the app searched for (com.stripe for
|
|
15
|
+
* "stripe"), not someone else's server for it. Set by searchMcpServers. */
|
|
16
|
+
official?: boolean;
|
|
17
|
+
/** How a key is sent, when it isn't Authorization: Bearer. */
|
|
18
|
+
auth?: {
|
|
19
|
+
header?: string;
|
|
20
|
+
prefix?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Extra headers the server needs (e.g. which toolsets to offer). */
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
export declare const MCP_REGISTRY_URL_ENV = "LOOPENGINE_MCP_REGISTRY_URL";
|
|
26
|
+
export declare const MCP_SERVERS_FILE_ENV = "LOOPENGINE_MCP_SERVERS_FILE";
|
|
27
|
+
/** The workspace's saved servers. A malformed entry is skipped. */
|
|
28
|
+
export declare function savedMcpServers(): McpDirectoryServer[];
|
|
29
|
+
/** Saved servers matching `query`, then registry ones, verified first. */
|
|
30
|
+
export declare function searchMcpServers(query: string, limit?: number): Promise<McpDirectoryServer[]>;
|
|
31
|
+
/** One server by id: a saved one, else the registry's latest entry. */
|
|
32
|
+
export declare function getMcpServer(id: string): Promise<McpDirectoryServer | undefined>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Where the workflow planner finds MCP servers: the workspace's own saved
|
|
2
|
+
// servers (mcp-servers.yml in the project root, or the file
|
|
3
|
+
// LOOPENGINE_MCP_SERVERS_FILE names), then the official MCP Registry.
|
|
4
|
+
// Only remote servers are offered: a local one from a public listing
|
|
5
|
+
// would mean running someone else's code here without anyone choosing to.
|
|
6
|
+
//
|
|
7
|
+
// servers:
|
|
8
|
+
// - id: acme-crm
|
|
9
|
+
// title: Acme CRM
|
|
10
|
+
// description: Our internal CRM
|
|
11
|
+
// url: https://mcp.acme.internal/mcp
|
|
12
|
+
// auth: { header: X-Api-Key } # optional; default Authorization: Bearer
|
|
13
|
+
//
|
|
14
|
+
// A registry server counts as verified when its URL is on the domain its
|
|
15
|
+
// registry name was verified for (com.stripe/mcp at mcp.stripe.com); the
|
|
16
|
+
// rest are shown as unverified.
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { parse as parseYaml } from 'yaml';
|
|
20
|
+
export const MCP_REGISTRY_URL_ENV = 'LOOPENGINE_MCP_REGISTRY_URL';
|
|
21
|
+
export const MCP_SERVERS_FILE_ENV = 'LOOPENGINE_MCP_SERVERS_FILE';
|
|
22
|
+
const DEFAULT_REGISTRY = 'https://registry.modelcontextprotocol.io';
|
|
23
|
+
const CACHE_MS = 10 * 60 * 1000;
|
|
24
|
+
function registryBase() {
|
|
25
|
+
const value = process.env[MCP_REGISTRY_URL_ENV]?.trim();
|
|
26
|
+
if (value === 'off')
|
|
27
|
+
return undefined;
|
|
28
|
+
return (value || DEFAULT_REGISTRY).replace(/\/+$/, '');
|
|
29
|
+
}
|
|
30
|
+
/** The workspace's saved servers. A malformed entry is skipped. */
|
|
31
|
+
export function savedMcpServers() {
|
|
32
|
+
const path = resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return [];
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = parseYaml(readFileSync(path, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const list = raw?.servers;
|
|
43
|
+
return (Array.isArray(list) ? list : []).flatMap((s) => {
|
|
44
|
+
const e = (s ?? {});
|
|
45
|
+
if (typeof e.id !== 'string' || typeof e.url !== 'string' || !isRemoteUrl(e.url))
|
|
46
|
+
return [];
|
|
47
|
+
const auth = (e.auth ?? undefined);
|
|
48
|
+
return [{
|
|
49
|
+
id: e.id,
|
|
50
|
+
title: typeof e.title === 'string' ? e.title : e.id,
|
|
51
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
52
|
+
url: e.url,
|
|
53
|
+
transport: e.transport === 'sse' ? 'sse' : 'streamable-http',
|
|
54
|
+
source: 'saved',
|
|
55
|
+
verified: true,
|
|
56
|
+
...(auth && (typeof auth.header === 'string' || typeof auth.prefix === 'string')
|
|
57
|
+
? { auth: { ...(typeof auth.header === 'string' ? { header: auth.header } : {}), ...(typeof auth.prefix === 'string' ? { prefix: auth.prefix } : {}) } }
|
|
58
|
+
: {}),
|
|
59
|
+
}];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function isRemoteUrl(url) {
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(url);
|
|
65
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
66
|
+
// A URL with a {placeholder} (a token in the path) needs filling in
|
|
67
|
+
// by hand, so it can't be connected to as listed.
|
|
68
|
+
return (u.protocol === 'https:' || (u.protocol === 'http:' && local)) && !/[{}]|%7B|%7D/i.test(url);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** io.github.firecrawl/… → firecrawl: the GitHub organisation the
|
|
75
|
+
* registry verified the name for. */
|
|
76
|
+
function githubOrg(name) {
|
|
77
|
+
const ns = name.split('/')[0];
|
|
78
|
+
return ns.startsWith('io.github.') ? ns.slice('io.github.'.length).toLowerCase() || undefined : undefined;
|
|
79
|
+
}
|
|
80
|
+
/** com.stripe/mcp → stripe.com; io.github.* names aren't domains. */
|
|
81
|
+
function publisherDomain(name) {
|
|
82
|
+
const ns = name.split('/')[0];
|
|
83
|
+
if (!ns || ns.startsWith('io.github.'))
|
|
84
|
+
return undefined;
|
|
85
|
+
const parts = ns.split('.');
|
|
86
|
+
return parts.length >= 2 ? parts.reverse().join('.') : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Whether the server's URL is on its publisher's own domain: the
|
|
89
|
+
* domain a reverse-domain name was verified for, or, for a name verified
|
|
90
|
+
* through a GitHub organisation, a domain named like it
|
|
91
|
+
* (io.github.firecrawl at mcp.firecrawl.dev). */
|
|
92
|
+
function onPublisherDomain(name, url) {
|
|
93
|
+
let host;
|
|
94
|
+
try {
|
|
95
|
+
host = new URL(url).hostname;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const domain = publisherDomain(name);
|
|
101
|
+
if (domain)
|
|
102
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
103
|
+
const org = githubOrg(name);
|
|
104
|
+
const labels = host.split('.');
|
|
105
|
+
return Boolean(org && labels.length >= 2 && labels[labels.length - 2] === org);
|
|
106
|
+
}
|
|
107
|
+
/** The publisher's own name: firecrawl for io.github.firecrawl, stripe for com.stripe. */
|
|
108
|
+
function publisherLabel(name) {
|
|
109
|
+
return githubOrg(name) ?? publisherDomain(name)?.split('.')[0];
|
|
110
|
+
}
|
|
111
|
+
function fromRegistry(entry) {
|
|
112
|
+
const s = entry.server;
|
|
113
|
+
if (!s?.name)
|
|
114
|
+
return undefined;
|
|
115
|
+
const remotes = (s.remotes ?? []).filter((r) => typeof r.url === 'string' && isRemoteUrl(r.url) && (r.type === 'streamable-http' || r.type === 'sse'));
|
|
116
|
+
// Streamable HTTP when offered (the current transport), and the
|
|
117
|
+
// full-access endpoint before a read-only variant.
|
|
118
|
+
const remote = remotes.find((r) => r.type === 'streamable-http' && !/readonly|read-only/i.test(r.url)) ?? remotes.find((r) => r.type === 'streamable-http') ?? remotes[0];
|
|
119
|
+
if (!remote)
|
|
120
|
+
return undefined;
|
|
121
|
+
const icons = (s.icons ?? []).filter((i) => typeof i.src === 'string' && i.src.startsWith('https://'));
|
|
122
|
+
const icon = (icons.find((i) => i.mimeType === 'image/svg+xml') ?? icons[0])?.src;
|
|
123
|
+
return {
|
|
124
|
+
id: s.name,
|
|
125
|
+
title: s.title || s.name.split('/').pop() || s.name,
|
|
126
|
+
description: (s.description ?? '').slice(0, 300),
|
|
127
|
+
url: remote.url,
|
|
128
|
+
transport: remote.type === 'sse' ? 'sse' : 'streamable-http',
|
|
129
|
+
...(s.websiteUrl ? { website: s.websiteUrl } : {}),
|
|
130
|
+
...(icon ? { icon } : {}),
|
|
131
|
+
source: 'registry',
|
|
132
|
+
verified: onPublisherDomain(s.name, remote.url),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const searches = new Map();
|
|
136
|
+
async function registrySearch(query) {
|
|
137
|
+
const base = registryBase();
|
|
138
|
+
if (!base || !query)
|
|
139
|
+
return [];
|
|
140
|
+
const key = `${base}\0${query.toLowerCase()}`;
|
|
141
|
+
const hit = searches.get(key);
|
|
142
|
+
if (hit && Date.now() - hit.at < CACHE_MS)
|
|
143
|
+
return hit.servers;
|
|
144
|
+
const url = `${base}/v0/servers?search=${encodeURIComponent(query)}&version=latest&limit=50`;
|
|
145
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10000), headers: { accept: 'application/json' } });
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
throw new Error(`the MCP Registry answered ${res.status}`);
|
|
148
|
+
const body = (await res.json());
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const servers = (body.servers ?? []).flatMap((e) => {
|
|
151
|
+
const s = fromRegistry(e);
|
|
152
|
+
if (!s || seen.has(s.id))
|
|
153
|
+
return [];
|
|
154
|
+
seen.add(s.id);
|
|
155
|
+
return [s];
|
|
156
|
+
});
|
|
157
|
+
searches.set(key, { at: Date.now(), servers });
|
|
158
|
+
return servers;
|
|
159
|
+
}
|
|
160
|
+
/** Saved servers matching `query`, then registry ones, verified first. */
|
|
161
|
+
export async function searchMcpServers(query, limit = 10) {
|
|
162
|
+
const q = query.trim().toLowerCase();
|
|
163
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
164
|
+
const saved = savedMcpServers().filter((s) => words.some((w) => `${s.id} ${s.title} ${s.description}`.toLowerCase().includes(w)));
|
|
165
|
+
let registry = [];
|
|
166
|
+
try {
|
|
167
|
+
// The registry matches names, so search on the first word ("linear
|
|
168
|
+
// issues" → "linear") when the whole phrase finds nothing.
|
|
169
|
+
registry = await registrySearch(q);
|
|
170
|
+
if (!registry.length && words.length > 1)
|
|
171
|
+
registry = await registrySearch(words[0]);
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
const ranked = registry
|
|
175
|
+
.map((r) => ({ ...r, official: r.verified && words.includes(publisherLabel(r.id) ?? '') }))
|
|
176
|
+
.sort((a, b) => Number(b.official) - Number(a.official) || Number(b.verified) - Number(a.verified));
|
|
177
|
+
return [...saved, ...ranked.filter((r) => !saved.some((s) => s.url === r.url))].slice(0, limit);
|
|
178
|
+
}
|
|
179
|
+
/** One server by id: a saved one, else the registry's latest entry. */
|
|
180
|
+
export async function getMcpServer(id) {
|
|
181
|
+
const saved = savedMcpServers().find((s) => s.id === id);
|
|
182
|
+
if (saved)
|
|
183
|
+
return saved;
|
|
184
|
+
const name = id.split('/').pop() ?? id;
|
|
185
|
+
try {
|
|
186
|
+
return (await registrySearch(name)).find((s) => s.id === id) ?? (await registrySearch(id)).find((s) => s.id === id);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/dist/core/run-agent.js
CHANGED
|
@@ -19,6 +19,7 @@ import { recordUsage } from './usage.js';
|
|
|
19
19
|
import { loadAgentModule } from './discover-agents.js';
|
|
20
20
|
import { agentAsTool } from './agent-as-tool.js';
|
|
21
21
|
import { loadGatewayToolsFromDir } from './gateway-tools.js';
|
|
22
|
+
import { loadMcpAbilityTools } from './mcp-abilities.js';
|
|
22
23
|
import { systemTools, createAskUserTool, createScheduleTools, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
|
|
23
24
|
import { resolveHttpNotifier } from './http-notifier.js';
|
|
24
25
|
import { CRASH_RECOVERY_CONTINUATION } from './session-store.js';
|
|
@@ -177,7 +178,9 @@ async function loadToolsFromDir(toolsDir) {
|
|
|
177
178
|
return [];
|
|
178
179
|
}
|
|
179
180
|
export async function loadDefaultTools(config) {
|
|
180
|
-
|
|
181
|
+
// MCP abilities' tools (core/mcp-abilities.ts) have no files in tools/,
|
|
182
|
+
// but are installed ability tools all the same, so they come with it.
|
|
183
|
+
return [...(await loadToolsFromDir(resolveAgentCodeDir(config.name, 'tools', true))), ...(await loadMcpAbilityTools(agentDirFor(config), config.name))];
|
|
181
184
|
}
|
|
182
185
|
/** Every other folder-form default in this file (loadDefaultTools,
|
|
183
186
|
* loadRules, the skillsDirs default below) resolves against
|
|
@@ -210,7 +213,7 @@ export async function loadDefaultTools(config) {
|
|
|
210
213
|
* gateway-tools.yml the way it opts out of the tools/ folder default. */
|
|
211
214
|
async function resolveSubagentConfig(config, dir) {
|
|
212
215
|
const resolved = { ...config };
|
|
213
|
-
const baseTools = resolved.tools === undefined ? await loadToolsFromDir(join(dir, 'tools')) : resolved.tools;
|
|
216
|
+
const baseTools = resolved.tools === undefined ? [...(await loadToolsFromDir(join(dir, 'tools'))), ...(await loadMcpAbilityTools(dir, config.name))] : resolved.tools;
|
|
214
217
|
resolved.tools = [...baseTools, ...(await loadGatewayToolsFromDir(dir))];
|
|
215
218
|
if (resolved.rules === undefined) {
|
|
216
219
|
const rulesPath = join(dir, 'actauth.yml');
|
|
@@ -5,7 +5,7 @@ export type InterviewStage = (typeof INTERVIEW_STAGES)[number];
|
|
|
5
5
|
export interface CandidateNeed {
|
|
6
6
|
/** ability: a LoopEngine ability (name = its package). api: a system to
|
|
7
7
|
* connect. key: a secret to add. messaging: an app for approvals. */
|
|
8
|
-
kind: 'ability' | 'api' | 'key' | 'messaging' | 'other';
|
|
8
|
+
kind: 'ability' | 'app' | 'api' | 'key' | 'messaging' | 'other';
|
|
9
9
|
name?: string;
|
|
10
10
|
description: string;
|
|
11
11
|
}
|
|
@@ -138,7 +138,7 @@ export function validateCandidate(raw) {
|
|
|
138
138
|
const needs = Array.isArray(c.needs)
|
|
139
139
|
? c.needs.flatMap((n) => {
|
|
140
140
|
const need = (n ?? {});
|
|
141
|
-
const needKind = ['ability', 'api', 'key', 'messaging', 'other'].includes(need.kind) ? need.kind : 'other';
|
|
141
|
+
const needKind = ['ability', 'app', 'api', 'key', 'messaging', 'other'].includes(need.kind) ? need.kind : 'other';
|
|
142
142
|
const description = str(need.description, 300);
|
|
143
143
|
return description ? [{ kind: needKind, ...(str(need.name, 120) ? { name: str(need.name, 120) } : {}), description }] : [];
|
|
144
144
|
})
|
|
@@ -22,9 +22,20 @@ export interface TemplateSchedule {
|
|
|
22
22
|
/** What the agent receives on each run. */
|
|
23
23
|
message: string;
|
|
24
24
|
}
|
|
25
|
+
/** An app (core/mcp-apps.ts) a template works through, and the server
|
|
26
|
+
* tools it relies on: switched on when the agent is created, if the app is
|
|
27
|
+
* connected; otherwise connecting it heads the checklist. */
|
|
28
|
+
export interface TemplateApp {
|
|
29
|
+
/** Its MCP Registry name, as in the Apps catalog. */
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
tools: string[];
|
|
33
|
+
}
|
|
25
34
|
export interface TemplateSetup {
|
|
26
35
|
/** npm names of the abilities to install. */
|
|
27
36
|
abilities: string[];
|
|
37
|
+
/** Apps it uses (connected on the Apps page). */
|
|
38
|
+
apps?: TemplateApp[];
|
|
28
39
|
/** Keys the agent needs before it can work, from its abilities' declared
|
|
29
40
|
* settings: a name, or a list where any one of them will do. */
|
|
30
41
|
keys: (string | string[])[];
|