create-loopengine 0.1.78 → 0.2.1
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/package.json +1 -1
- package/template/adapters/http.ts +290 -10
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
|
|
30
30
|
import { execFile } from 'node:child_process'
|
|
31
31
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
32
|
-
import { createReadStream, existsSync } from 'node:fs'
|
|
32
|
+
import { createReadStream, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
33
33
|
import { stat } from 'node:fs/promises'
|
|
34
34
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
35
35
|
import { extname, join, resolve as resolvePath, sep } from 'node:path'
|
|
@@ -183,6 +183,7 @@ import {
|
|
|
183
183
|
removeAbility,
|
|
184
184
|
listInstalledAbilities,
|
|
185
185
|
backfillDependencies,
|
|
186
|
+
configureAgentMcpTools,
|
|
186
187
|
AbilityAlreadyInstalledError,
|
|
187
188
|
AbilityCollisionError,
|
|
188
189
|
AbilityManifestError,
|
|
@@ -204,14 +205,35 @@ import {
|
|
|
204
205
|
runWorkflowNow,
|
|
205
206
|
startPlanJob,
|
|
206
207
|
type WorkflowAgentInfo,
|
|
208
|
+
type WorkflowMcpDeps,
|
|
207
209
|
workflowPageHtml,
|
|
208
210
|
MAX_UPLOAD_BYTES,
|
|
209
211
|
installUploadedAbility,
|
|
210
212
|
installWorkspaceAbility,
|
|
211
213
|
type AbilityUpload,
|
|
212
214
|
WorkspaceAbilityError,
|
|
215
|
+
addWorkspaceAbility,
|
|
213
216
|
listWorkspaceAbilities,
|
|
214
217
|
removeWorkspaceAbility,
|
|
218
|
+
getMcpServer,
|
|
219
|
+
searchMcpServers,
|
|
220
|
+
isMcpAuthError,
|
|
221
|
+
listMcpServerTools,
|
|
222
|
+
resolveMcpManifest,
|
|
223
|
+
McpAbilityGenerationError,
|
|
224
|
+
generateMcpAbility,
|
|
225
|
+
mcpAbilityNames,
|
|
226
|
+
readAbilityDetails,
|
|
227
|
+
AppToolsError,
|
|
228
|
+
appCatalog,
|
|
229
|
+
appServer,
|
|
230
|
+
appTools,
|
|
231
|
+
connectedApps,
|
|
232
|
+
isAppAbility,
|
|
233
|
+
loadAppIcons,
|
|
234
|
+
refreshAppTools,
|
|
235
|
+
setAppDefault,
|
|
236
|
+
type AgentAppTools,
|
|
215
237
|
AbilitySourceError,
|
|
216
238
|
abilityOrigin,
|
|
217
239
|
readAbilitySources,
|
|
@@ -532,7 +554,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
532
554
|
skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
|
|
533
555
|
skillsDirs: skillsDirs,
|
|
534
556
|
// How many abilities are installed — just the count, for the console's
|
|
535
|
-
// tab badge; the
|
|
557
|
+
// tab badge; the Apps tab's own GET .../abilities has the detail
|
|
536
558
|
// (and its slower npm version lookups).
|
|
537
559
|
abilityCount: listInstalledAbilities(config.name).length,
|
|
538
560
|
skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
@@ -1067,6 +1089,228 @@ async function workflowAgentInfo(): Promise<WorkflowAgentInfo[]> {
|
|
|
1067
1089
|
)
|
|
1068
1090
|
}
|
|
1069
1091
|
|
|
1092
|
+
// Steps no ability can do can use an app's MCP server
|
|
1093
|
+
// (core/mcp-directory.ts), made into a private ability on Create.
|
|
1094
|
+
const workflowMcp: WorkflowMcpDeps = {
|
|
1095
|
+
getServer: getMcpServer,
|
|
1096
|
+
// The workspace ability already using the server at `url`.
|
|
1097
|
+
existingFor(url) {
|
|
1098
|
+
for (const found of listWorkspaceAbilities()) {
|
|
1099
|
+
try {
|
|
1100
|
+
const manifest = JSON.parse(readFileSync(join(found.spec.replace(/^file:/, ''), 'loopengine.ability.json'), 'utf8')) as { mcp?: { url?: string } }
|
|
1101
|
+
if (found.source !== `mcp:${url}` && manifest.mcp?.url !== url) continue
|
|
1102
|
+
return { spec: found.spec, tools: resolveMcpManifest(found.name, manifest.mcp).tools }
|
|
1103
|
+
} catch {}
|
|
1104
|
+
}
|
|
1105
|
+
return undefined
|
|
1106
|
+
},
|
|
1107
|
+
keyValue: (name) => process.env[name],
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** A server's tools for the planner, or why they can't be seen yet. */
|
|
1111
|
+
async function workflowMcpServerTools(id: string): Promise<unknown> {
|
|
1112
|
+
const server = await getMcpServer(id)
|
|
1113
|
+
if (!server) return `No MCP server ${id}.`
|
|
1114
|
+
const { keyEnv } = mcpAbilityNames(server)
|
|
1115
|
+
const key = process.env[keyEnv]
|
|
1116
|
+
try {
|
|
1117
|
+
const tools = await listMcpServerTools(
|
|
1118
|
+
{ url: server.url, ...(server.transport === 'sse' ? { transport: 'sse' as const } : {}), auth: key ? { type: 'key', env: keyEnv, ...(server.auth ?? {}) } : { type: 'none' } },
|
|
1119
|
+
{ get: (n) => (n === keyEnv ? key : undefined), require: (n) => { if (n === keyEnv && key) return key; throw new Error(`${n} is not set`) } },
|
|
1120
|
+
)
|
|
1121
|
+
return { server: server.title, tools: tools.slice(0, 80).map((t) => ({ name: t.name, description: t.description.split('\n')[0].slice(0, 200), ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}) })) }
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1124
|
+
return isMcpAuthError(err)
|
|
1125
|
+
? `${server.title} needs a key before its tools can be seen. Plan its steps by what they do (leave "tool" empty); the person adds the key on the plan, and its tools are picked on Create.`
|
|
1126
|
+
: `Couldn't reach ${server.title}: ${message.slice(0, 200)}`
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Each agent's tools from the app `abilityName`, with the permission
|
|
1131
|
+
* each has there (its first rule for the tool). */
|
|
1132
|
+
function agentAppTools(abilityName: string): AgentAppTools {
|
|
1133
|
+
const out: AgentAppTools = {}
|
|
1134
|
+
for (const agent of listAgents()) {
|
|
1135
|
+
let record: ReturnType<typeof listInstalledAbilities>[number] | undefined
|
|
1136
|
+
try {
|
|
1137
|
+
record = listInstalledAbilities(agent).find((a) => a.name === abilityName && a.mcp)
|
|
1138
|
+
} catch {}
|
|
1139
|
+
if (!record?.mcp) continue
|
|
1140
|
+
const rules = readActauthConfig(agent).rules
|
|
1141
|
+
const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
|
|
1142
|
+
out[agent] = record.mcp.tools.map((t) => {
|
|
1143
|
+
const d = rules.find((r) => r.tool === t.name)?.decision
|
|
1144
|
+
return { remote: t.remote, decision: decided(d) ? d : 'ask' }
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
return out
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Adds more of an app's tools to an agent that has it, each at the
|
|
1151
|
+
* app's default permission, keeping the agent's current ones. */
|
|
1152
|
+
function addAgentAppTools(agent: string, spec: string, tools: string[]): void {
|
|
1153
|
+
const dir = spec.replace(/^file:/, '')
|
|
1154
|
+
const name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name: string }).name
|
|
1155
|
+
const current = agentAppTools(name)[agent] ?? []
|
|
1156
|
+
const defaults = readAbilityDetails(dir).tools
|
|
1157
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'loopengine.ability.json'), 'utf8')) as { mcp?: unknown }
|
|
1158
|
+
const all = resolveMcpManifest(name, manifest.mcp).tools
|
|
1159
|
+
const choice = [...current]
|
|
1160
|
+
for (const remote of tools) {
|
|
1161
|
+
if (choice.some((c) => c.remote === remote)) continue
|
|
1162
|
+
const installed = all.find((t) => t.remote === remote)?.name
|
|
1163
|
+
const d = defaults.find((t) => t.name === installed)?.decision
|
|
1164
|
+
choice.push({ remote, decision: d === 'allow' || d === 'deny' ? d : 'ask' })
|
|
1165
|
+
}
|
|
1166
|
+
configureAgentMcpTools(agent, name, dir, choice)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** For a template or workflow: the connected app's workspace spec, or
|
|
1170
|
+
* undefined when it isn't connected. Its tools are chosen at install. */
|
|
1171
|
+
async function prepareWorkspaceApp(id: string): Promise<string | undefined> {
|
|
1172
|
+
const server = await appServer(id)
|
|
1173
|
+
return describeApps().connected.find((c) => c.id === id || (server && c.url === server.url))?.spec
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function describeApps(): { apps: ReturnType<typeof appCatalog>; connected: ReturnType<typeof connectedApps>; canConnect: boolean } {
|
|
1177
|
+
const agentAbilities: Record<string, string[]> = {}
|
|
1178
|
+
for (const name of listAgents()) {
|
|
1179
|
+
try {
|
|
1180
|
+
agentAbilities[name] = listInstalledAbilities(name).map((a) => a.name)
|
|
1181
|
+
} catch {}
|
|
1182
|
+
}
|
|
1183
|
+
const connected = connectedApps(listWorkspaceAbilities(), agentAbilities, (name) => process.env[name])
|
|
1184
|
+
return { apps: appCatalog(connected), connected, canConnect: secretsProtected }
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function handleAgentApp(req: IncomingMessage, res: ServerResponse, agent: string, ability: string): Promise<void> {
|
|
1188
|
+
const json = (status: number, body: unknown): void => {
|
|
1189
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
if (!getEntry(agent)) return json(404, { error: `unknown agent '${agent}'` })
|
|
1193
|
+
const app = describeApps().connected.find((c) => c.ability === ability)
|
|
1194
|
+
if (!app) return json(400, { error: 'That app isn\'t connected.' })
|
|
1195
|
+
const dir = app.spec.replace(/^file:/, '')
|
|
1196
|
+
const envValue = (name: string) => process.env[name]
|
|
1197
|
+
const view = async () => {
|
|
1198
|
+
const mine = agentAppTools(ability)[agent]
|
|
1199
|
+
const all = await appTools(app.spec, envValue, {})
|
|
1200
|
+
return {
|
|
1201
|
+
app: { title: app.title, ability },
|
|
1202
|
+
installed: Boolean(mine),
|
|
1203
|
+
tools: all.map((t) => {
|
|
1204
|
+
const chosen = mine?.find((m) => m.remote === t.remote)
|
|
1205
|
+
return { remote: t.remote, name: t.name, description: t.description, ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}), ...(t.destructive ? { destructive: true } : {}), default: t.decision, selected: Boolean(chosen), decision: chosen?.decision ?? t.decision }
|
|
1206
|
+
}),
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (req.method === 'GET') return json(200, await view())
|
|
1210
|
+
if (req.method !== 'PUT') return json(404, { error: 'not found' })
|
|
1211
|
+
if (!secretsProtected) return json(403, { error: 'Changing an agent\'s tools needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1212
|
+
const body = await readJsonBody(req)
|
|
1213
|
+
const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
|
|
1214
|
+
const choice = (Array.isArray(body.tools) ? body.tools : []).flatMap((t: unknown) => {
|
|
1215
|
+
const e = (t ?? {}) as { remote?: unknown; decision?: unknown }
|
|
1216
|
+
return typeof e.remote === 'string' && decided(e.decision) ? [{ remote: e.remote, decision: e.decision }] : []
|
|
1217
|
+
})
|
|
1218
|
+
if (!choice.length) return json(400, { error: 'Choose at least one tool, or remove the app from the agent instead.' })
|
|
1219
|
+
if (agentAppTools(ability)[agent]) configureAgentMcpTools(agent, ability, dir, choice)
|
|
1220
|
+
else await installAbility(agent, app.spec, { mcpTools: choice.map((c) => c.remote), mcpDecisions: Object.fromEntries(choice.map((c) => [c.remote, c.decision])) })
|
|
1221
|
+
return json(200, await view())
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1224
|
+
if (err instanceof AbilityManifestError || err instanceof AbilityCollisionError || err instanceof AppToolsError) return json(400, { error: message })
|
|
1225
|
+
console.error('[loopengine] agent app failed:', err)
|
|
1226
|
+
return json(502, { error: message })
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async function handleApps(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
|
|
1231
|
+
const json = (status: number, body: unknown): void => {
|
|
1232
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
if (pathname === '/config/apps' && req.method === 'GET') {
|
|
1236
|
+
// Icons arrive from the registry on a first load; wait briefly.
|
|
1237
|
+
await Promise.race([loadAppIcons(), new Promise((resolve) => setTimeout(resolve, 2500))])
|
|
1238
|
+
return json(200, describeApps())
|
|
1239
|
+
}
|
|
1240
|
+
if (pathname === '/config/apps/search' && req.method === 'GET') {
|
|
1241
|
+
const q = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')?.trim() ?? ''
|
|
1242
|
+
if (q.length < 2) return json(400, { error: 'Type at least two letters.' })
|
|
1243
|
+
return json(200, { servers: await searchMcpServers(q, 15) })
|
|
1244
|
+
}
|
|
1245
|
+
if (pathname === '/config/apps/connect' && req.method === 'POST') {
|
|
1246
|
+
if (!secretsProtected) return json(403, { error: 'Connecting an app saves its key on this server, so it needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1247
|
+
const body = await readJsonBody(req)
|
|
1248
|
+
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
1249
|
+
const key = typeof body.key === 'string' ? body.key.trim() : ''
|
|
1250
|
+
const server = id ? await appServer(id) : undefined
|
|
1251
|
+
if (!server) return json(400, { error: 'No such app.' })
|
|
1252
|
+
let picked: ReturnType<typeof pickInterviewModel>
|
|
1253
|
+
try {
|
|
1254
|
+
picked = pickInterviewModel(interviewAgents())
|
|
1255
|
+
} catch {
|
|
1256
|
+
picked = undefined
|
|
1257
|
+
}
|
|
1258
|
+
const generated = await generateMcpAbility({
|
|
1259
|
+
server,
|
|
1260
|
+
...(key ? { key } : {}),
|
|
1261
|
+
workflow: { name: `${server.title} for this workspace`, summary: `Everyday work with ${server.title}`, steps: [] },
|
|
1262
|
+
...(picked ? { modelCall: picked.modelCall } : {}),
|
|
1263
|
+
})
|
|
1264
|
+
try {
|
|
1265
|
+
addWorkspaceAbility(generated.dir, `mcp:${server.url}`)
|
|
1266
|
+
} finally {
|
|
1267
|
+
rmSync(generated.dir, { recursive: true, force: true })
|
|
1268
|
+
}
|
|
1269
|
+
if (key && generated.keyEnv) await setEnvVar(generated.keyEnv, key)
|
|
1270
|
+
return json(200, { ...describeApps(), connectedAbility: generated.name })
|
|
1271
|
+
}
|
|
1272
|
+
// A connected app's tools: everything its server offers, each with
|
|
1273
|
+
// its default permission and the agents that use it. Defaults only
|
|
1274
|
+
// affect agents that add a tool from now on.
|
|
1275
|
+
if (pathname === '/config/apps/tools' || pathname === '/config/apps/refresh') {
|
|
1276
|
+
const params = new URL(req.url ?? '/', 'http://localhost').searchParams
|
|
1277
|
+
const body = req.method === 'POST' ? await readJsonBody(req) : {}
|
|
1278
|
+
const ability = String(req.method === 'POST' ? body.ability ?? '' : params.get('ability') ?? '')
|
|
1279
|
+
const app = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
|
|
1280
|
+
if (!app) return json(400, { error: 'That app isn\'t connected.' })
|
|
1281
|
+
const envValue = (name: string) => process.env[name]
|
|
1282
|
+
const list = async (fresh = false) => appTools(app.spec, envValue, agentAppTools(app.name), { fresh })
|
|
1283
|
+
if (req.method === 'GET') return json(200, { tools: await list(params.get('fresh') === '1') })
|
|
1284
|
+
if (req.method !== 'POST') return json(404, { error: 'not found' })
|
|
1285
|
+
if (!secretsProtected) return json(403, { error: 'Changing an app needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1286
|
+
if (pathname === '/config/apps/refresh') {
|
|
1287
|
+
const changed = await refreshAppTools(app.spec, envValue)
|
|
1288
|
+
return json(200, { tools: await list(), ...changed, ...describeApps() })
|
|
1289
|
+
}
|
|
1290
|
+
const tool = typeof body.tool === 'string' ? body.tool : ''
|
|
1291
|
+
const decision = body.decision === 'allow' || body.decision === 'ask' || body.decision === 'deny' ? body.decision : undefined
|
|
1292
|
+
if (!tool || !decision) return json(400, { error: 'tool and decision are required' })
|
|
1293
|
+
setAppDefault(app.spec, tool, decision)
|
|
1294
|
+
return json(200, { tools: await list() })
|
|
1295
|
+
}
|
|
1296
|
+
if (pathname === '/config/apps/connect' && req.method === 'DELETE') {
|
|
1297
|
+
if (!secretsProtected) return json(403, { error: 'Disconnecting an app needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1298
|
+
const ability = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ability') ?? ''
|
|
1299
|
+
const found = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
|
|
1300
|
+
if (!found) return json(400, { error: 'That app isn\'t connected.' })
|
|
1301
|
+
removeWorkspaceAbility(found.name)
|
|
1302
|
+
return json(200, describeApps())
|
|
1303
|
+
}
|
|
1304
|
+
return json(404, { error: 'not found' })
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1307
|
+
if (err instanceof McpAbilityGenerationError || err instanceof WorkspaceAbilityError || err instanceof AppToolsError) return json(400, { error: message })
|
|
1308
|
+
if (isMcpAuthError(err)) return json(400, { error: 'The app turned down its saved key; reconnect it with a new one.' })
|
|
1309
|
+
console.error('[loopengine] apps failed:', err)
|
|
1310
|
+
return json(502, { error: message })
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1070
1314
|
async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
|
|
1071
1315
|
const json = (status: number, body: unknown): void => {
|
|
1072
1316
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
@@ -1121,6 +1365,9 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1121
1365
|
return { name: d.name, tools: d.tools.map((t) => ({ name: t.name, description: t.description.slice(0, 300), decision: t.decision ?? null })), settings: d.settings.map((x) => x.name) }
|
|
1122
1366
|
},
|
|
1123
1367
|
listConnections: () => base.listConnections(),
|
|
1368
|
+
searchMcpServers: async (query) => (await searchMcpServers(query)).map((s) => ({ id: s.id, title: s.title, description: s.description, url: s.url, official: Boolean(s.official), verified: s.verified, source: s.source })),
|
|
1369
|
+
mcpServerTools: workflowMcpServerTools,
|
|
1370
|
+
mcp: workflowMcp,
|
|
1124
1371
|
}))
|
|
1125
1372
|
return json(202, job)
|
|
1126
1373
|
}
|
|
@@ -1128,6 +1375,23 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1128
1375
|
const body = await readJsonBody(req)
|
|
1129
1376
|
const model = pickAgentModel(interviewAgents())
|
|
1130
1377
|
const created = await createWorkflow(body, {
|
|
1378
|
+
addAppTools: async (agent, spec, tools) => addAgentAppTools(agent, spec, tools),
|
|
1379
|
+
mcp: {
|
|
1380
|
+
...workflowMcp,
|
|
1381
|
+
// The planning model picks the server's tools and writes the skill.
|
|
1382
|
+
generate: (request) => generateMcpAbility({ ...request, ...(picked ? { modelCall: picked.modelCall } : {}) }),
|
|
1383
|
+
store: (dir, source) => {
|
|
1384
|
+
try {
|
|
1385
|
+
return addWorkspaceAbility(dir, source).spec
|
|
1386
|
+
} finally {
|
|
1387
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1388
|
+
}
|
|
1389
|
+
},
|
|
1390
|
+
saveKey: async (name, value) => {
|
|
1391
|
+
if (!secretsProtected) throw new Error('saving keys needs LOOPENGINE_ADMIN_AUTH set on the server')
|
|
1392
|
+
await setEnvVar(name, value)
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1131
1395
|
agentExists: (name) => Boolean(getEntry(name)) || existsSync(dirOf(name)),
|
|
1132
1396
|
agentDir: dirOf,
|
|
1133
1397
|
agentInfo: workflowAgentInfo,
|
|
@@ -1148,9 +1412,10 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1148
1412
|
const indexPath = join(dirOf(name), 'index.ts')
|
|
1149
1413
|
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
1150
1414
|
},
|
|
1151
|
-
// As for templates and the
|
|
1415
|
+
// As for templates and the Apps tab: installing runs
|
|
1152
1416
|
// third-party code, so only behind the admin login.
|
|
1153
|
-
...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
|
|
1417
|
+
...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
|
|
1418
|
+
prepareApp: prepareWorkspaceApp,
|
|
1154
1419
|
envValue: (name, variable) => createAgentEnv(name, dirOf(name)).get(variable),
|
|
1155
1420
|
messagingConfigured: (name) => {
|
|
1156
1421
|
const entry = getEntry(name)
|
|
@@ -1224,10 +1489,11 @@ async function handleStarterTemplates(req: IncomingMessage, res: ServerResponse,
|
|
|
1224
1489
|
const indexPath = join(dirOf(name), 'index.ts')
|
|
1225
1490
|
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
1226
1491
|
},
|
|
1227
|
-
// Same rule as the
|
|
1492
|
+
// Same rule as the Apps tab: installing runs third-party code,
|
|
1228
1493
|
// so only behind the admin login. The agent isn't loaded yet, so
|
|
1229
1494
|
// nothing needs a restart.
|
|
1230
|
-
...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
|
|
1495
|
+
...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
|
|
1496
|
+
prepareApp: prepareWorkspaceApp,
|
|
1231
1497
|
envValue,
|
|
1232
1498
|
messagingConfigured: (name) => {
|
|
1233
1499
|
const entry = getEntry(name)
|
|
@@ -1821,7 +2087,7 @@ function hasNodeModule(name: string): boolean {
|
|
|
1821
2087
|
return existsSync(join(process.cwd(), 'node_modules', name))
|
|
1822
2088
|
}
|
|
1823
2089
|
|
|
1824
|
-
// Backs the Admin UI's
|
|
2090
|
+
// Backs the Admin UI's Apps tab list — everything currently
|
|
1825
2091
|
// installed for this agent, each annotated with the latest version
|
|
1826
2092
|
// currently published on the public npm registry (fetchLatestAbilityVersion,
|
|
1827
2093
|
// best-effort — null for a private/file:/git-installed ability, or on any
|
|
@@ -1861,7 +2127,7 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
|
|
|
1861
2127
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
|
|
1862
2128
|
}
|
|
1863
2129
|
|
|
1864
|
-
// Backs the
|
|
2130
|
+
// Backs the Apps tab's search box — proxies to npm's own public
|
|
1865
2131
|
// registry search (loopengine's own searchPublicAbilities) so the
|
|
1866
2132
|
// browser never needs direct access to registry.npmjs.org, and the
|
|
1867
2133
|
// actual query text (the loopengine-ability keyword filter) stays a
|
|
@@ -3676,6 +3942,13 @@ const server = createServer(async (req, res) => {
|
|
|
3676
3942
|
await handleWorkflows(req, res, pathname)
|
|
3677
3943
|
return
|
|
3678
3944
|
}
|
|
3945
|
+
// An agent's tools from one connected app: which it has, and each
|
|
3946
|
+
// one's permission for this agent (the Apps tab's app picker).
|
|
3947
|
+
const agentAppMatch = pathname.match(/^\/agents\/([^/]+)\/apps\/([^/]+)$/)
|
|
3948
|
+
if (agentAppMatch) {
|
|
3949
|
+
await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
|
|
3950
|
+
return
|
|
3951
|
+
}
|
|
3679
3952
|
const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
|
|
3680
3953
|
if (agentWorkflowsMatch) {
|
|
3681
3954
|
await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
|
|
@@ -4041,7 +4314,7 @@ const server = createServer(async (req, res) => {
|
|
|
4041
4314
|
return
|
|
4042
4315
|
}
|
|
4043
4316
|
|
|
4044
|
-
// Backs the Admin UI's
|
|
4317
|
+
// Backs the Admin UI's Apps tab — see handleAbilitiesGet/
|
|
4045
4318
|
// handleAbilitiesSearchGet/handleAbilityInstallPost's own doc
|
|
4046
4319
|
// comments above. The /search route is checked before the plain
|
|
4047
4320
|
// :agentName routes right below since 'search' would otherwise
|
|
@@ -4091,10 +4364,17 @@ const server = createServer(async (req, res) => {
|
|
|
4091
4364
|
}
|
|
4092
4365
|
return
|
|
4093
4366
|
}
|
|
4367
|
+
// The workspace's Apps (core/mcp-apps.ts): popular apps' MCP servers,
|
|
4368
|
+
// and the ones connected. Connecting stores a key and makes a
|
|
4369
|
+
// workspace ability, so like installing one it needs the admin login.
|
|
4370
|
+
if (pathname.startsWith('/config/apps')) {
|
|
4371
|
+
await handleApps(req, res, pathname)
|
|
4372
|
+
return
|
|
4373
|
+
}
|
|
4094
4374
|
// The workspace's private abilities (core/workspace-abilities.ts):
|
|
4095
4375
|
// installed from the Abilities page, added to agents from their tab.
|
|
4096
4376
|
if (pathname === '/abilities/workspace' && req.method === 'GET') {
|
|
4097
|
-
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
|
|
4377
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities().map((a) => ({ ...a, app: isAppAbility(a) })) }))
|
|
4098
4378
|
return
|
|
4099
4379
|
}
|
|
4100
4380
|
if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
|