create-loopengine 0.1.78 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.78",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 Abilities tab's own GET .../abilities has the detail
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 Abilities tab: installing runs
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 Abilities tab: installing runs third-party code,
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 Abilities tab list — everything currently
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 Abilities tab's search box — proxies to npm's own public
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 Abilities tab — see handleAbilitiesGet/
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')) {
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.97",
12
+ "loopengine": "^0.2.0",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"