@frontera-sdk/cli 1.52.48 → 1.53.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.48",
3
+ "version": "1.53.1",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.52.48",
44
- "@frontera-sdk/functions": "1.52.48",
43
+ "@frontera-sdk/core": "1.53.1",
44
+ "@frontera-sdk/functions": "1.53.1",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.52.48",
52
+ "@frontera-sdk/forge-contracts": "1.53.1",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -280,6 +280,22 @@ export class PlatformApi {
280
280
  return this.get<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/editor`)
281
281
  }
282
282
 
283
+ // ── Workspace MCP servers ─────────────────────────────────────────────────
284
+
285
+ mcpServers(): Promise<{ servers: unknown[] }> {
286
+ return this.get<{ servers: unknown[] }>('/v1/mcp-servers')
287
+ }
288
+
289
+ /** One server, with the tools the caller cannot use now and why (`unavailable`). */
290
+ mcpServer(slug: string): Promise<{ server: unknown }> {
291
+ return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
292
+ }
293
+
294
+ /** Archived, not deleted: the Function runs its tools started still name it. */
295
+ archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
296
+ return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
297
+ }
298
+
283
299
  /** Drafts are per principal: a key's draft is its own, not a person's. */
284
300
  saveChatAppDraft(appId: string, configuration: unknown) {
285
301
  return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/draft`, {
@@ -35,6 +35,7 @@ import { applyTheme, themeCalls } from '../../local/local-themes'
35
35
  import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
36
36
  import { fillPlaceholderMedia } from '../../local/local-media-placeholders'
37
37
  import { applyChatApp, keyCalls } from '../../local/local-chat-apps'
38
+ import { applyMcpServer } from '../../local/local-mcp-servers'
38
39
  import { applyLessons } from '../../local/local-lessons'
39
40
  import { applyStandIn, missingGrantInstalls, platformOnlyNames, readStandIn, standInNames, standInPath, writeGrantStandIn } from '../../local/local-stand-ins'
40
41
  import { projectProvides } from '../../local/local-adapt'
@@ -215,6 +216,11 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
215
216
  // The workspace key's draft too, so `frontera chat-app get` (which reads the key's own draft) shows what was applied.
216
217
  return withNotes(await applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId), creds.workspaceKey ? keyCalls(opts.serviceUrl, creds) : undefined))
217
218
  }
219
+ if (target.kind === 'mcp-server') {
220
+ // As the runtime's workspace key: the service checks each tool against what the runtime holds.
221
+ if (!creds?.workspaceKey) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
222
+ return withNotes(await applyMcpServer(project.root, target.name, target.file, keyCalls(opts.serviceUrl, creds)))
223
+ }
218
224
  if (target.kind === 'app') {
219
225
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
220
226
  return applyApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), projectProvides(project, creds.organizationId))
@@ -1454,7 +1460,7 @@ async function dev(ctx: CommandContext) {
1454
1460
  return { data: { applied: done, unchanged, ...alreadyField, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}${alreadyText}` }
1455
1461
  }
1456
1462
 
1457
- ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
1463
+ ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, mcp-servers/, brand/ and library.json (Ctrl+C to stop)')
1458
1464
  if (pending.size) schedule()
1459
1465
  // What failed while the runtime was down is applied once it answers again, not when its file next changes.
1460
1466
  const poller = setInterval(() => {
@@ -0,0 +1,96 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { UsageError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import type { Command } from '../types'
5
+
6
+ /**
7
+ * A workspace's own MCP servers: what is on the workspace, and taking one out
8
+ * of service.
9
+ *
10
+ * A server is authored as `mcp-servers/<slug>.yaml` in a project and saved by
11
+ * `frontera local dev`, which also archives one whose file was removed. These
12
+ * commands cover the rest: seeing what a workspace serves and where a client
13
+ * connects, why a tool is left out for you, and archiving a server the project
14
+ * never had a file for.
15
+ */
16
+
17
+ interface McpServerRow {
18
+ slug?: string
19
+ name?: string
20
+ url?: string
21
+ revision?: number
22
+ tools?: Array<{ name?: string; kind?: string; target?: string }>
23
+ unavailable?: Array<{ name?: string; reason?: string; temporary?: boolean }>
24
+ }
25
+
26
+ function serverRef(ctx: { positional: string[] }, verb: string): string {
27
+ const slug = ctx.positional[0]
28
+ if (!slug) throw new UsageError('missing <slug>', `frontera mcp-server list — then frontera mcp-server ${verb} <slug>`)
29
+ return slug
30
+ }
31
+
32
+ const list: Command = {
33
+ meta: {
34
+ noun: 'mcp-server',
35
+ verb: 'list',
36
+ args: [],
37
+ flags: {},
38
+ summary: 'List the MCP servers this workspace serves, and where a client connects to each',
39
+ examples: ['frontera mcp-server list', 'frontera mcp-server list --json'],
40
+ },
41
+ async run(ctx) {
42
+ const rows = ((await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).mcpServers()).servers ?? []) as McpServerRow[]
43
+ return {
44
+ data: rows,
45
+ text:
46
+ rows.length === 0
47
+ ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and run `frontera local dev` (`frontera local schema mcp-server` prints the format).'
48
+ : table(
49
+ ['slug', 'name', 'tools', 'url'],
50
+ rows.map((s) => [s.slug ?? '?', s.name ?? '', String(s.tools?.length ?? 0), s.url ?? '']),
51
+ ),
52
+ }
53
+ },
54
+ }
55
+
56
+ const get: Command = {
57
+ meta: {
58
+ noun: 'mcp-server',
59
+ verb: 'get',
60
+ args: [{ name: 'slug', required: true, description: 'MCP server slug' }],
61
+ flags: {},
62
+ summary: 'Show an MCP server: its tools, and any you cannot use now with the reason',
63
+ examples: ['frontera mcp-server get tracker'],
64
+ },
65
+ async run(ctx) {
66
+ const slug = serverRef(ctx, 'get')
67
+ const server = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).mcpServer(slug)).server as McpServerRow
68
+ const lines = [`${server.name ?? slug} (${slug}), revision ${server.revision ?? '?'}`, ` connect at ${server.url ?? '?'}`, '']
69
+ lines.push(table(['tool', 'kind', 'runs'], (server.tools ?? []).map((t) => [t.name ?? '?', t.kind ?? '', t.target ?? ''])))
70
+ for (const tool of server.unavailable ?? []) {
71
+ lines.push(` ${tool.temporary ? 'for now, ' : ''}${tool.name ?? '?'} is left out for you: ${tool.reason ?? 'unavailable'}`)
72
+ }
73
+ return { data: server, text: lines.join('\n') }
74
+ },
75
+ }
76
+
77
+ const archive: Command = {
78
+ meta: {
79
+ noun: 'mcp-server',
80
+ verb: 'archive',
81
+ args: [{ name: 'slug', required: true, description: 'MCP server slug' }],
82
+ flags: {},
83
+ summary: 'Archive an MCP server: its URL stops answering at once, and its slug can be taken again',
84
+ examples: ['frontera mcp-server archive tracker'],
85
+ },
86
+ async run(ctx) {
87
+ const slug = serverRef(ctx, 'archive')
88
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).archiveMcpServer(slug)
89
+ return {
90
+ data: result,
91
+ text: `Archived MCP server ${slug}: clients connected to it get 404 from now on. Saving mcp-servers/${slug}.yaml again creates it anew.`,
92
+ }
93
+ },
94
+ }
95
+
96
+ export const mcpServerCommands: Command[] = [list, get, archive]
@@ -41,6 +41,7 @@ import { mediaCommands } from './media/index-commands'
41
41
  import { packCommands } from './pack/index-commands'
42
42
  import { secretCommands } from './secret/index-commands'
43
43
  import { chatAppCommands } from './chat-app/index-commands'
44
+ import { mcpServerCommands } from './mcp-server/index-commands'
44
45
  import { markingCommands } from './marking/index-commands'
45
46
  import { automationCommands } from './automation/index-commands'
46
47
  import { workflowCommands } from './workflow/index-commands'
@@ -133,6 +134,7 @@ export const COMMANDS: readonly Command[] = [
133
134
  ...packCommands,
134
135
  ...secretCommands,
135
136
  ...chatAppCommands,
137
+ ...mcpServerCommands,
136
138
  ...automationCommands,
137
139
  ...workflowCommands,
138
140
 
@@ -210,6 +212,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
210
212
  pack: 'Reusable skill bundles — author once, install per workspace',
211
213
  secret: 'Workspace secrets — named here, never printed back',
212
214
  'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
215
+ 'mcp-server': 'Workspace MCP servers — list, inspect and archive',
213
216
  function: 'Functions — TypeScript deployed here, run on a schedule',
214
217
  workflow: 'Workflows — declared steps, versioned, promoted, then run',
215
218
  blueprint: 'The shared model of the organization — what an app can read',
@@ -399,6 +399,8 @@ export function refOfTarget(target: SyncTarget): string {
399
399
  return `chat-app/${target.name}`
400
400
  case 'app':
401
401
  return `app/${target.name}`
402
+ case 'mcp-server':
403
+ return `mcp-server/${target.name}`
402
404
  case 'brand':
403
405
  return 'brand'
404
406
  case 'library':
@@ -419,6 +421,7 @@ const KIND_WORDS: Record<string, string> = {
419
421
  plugin: 'Plugin',
420
422
  model: 'model',
421
423
  dataset: 'dataset',
424
+ 'mcp-server': 'MCP server',
422
425
  }
423
426
 
424
427
  /** `workflow/telco-fault-triage` → "workflow telco-fault-triage". */
@@ -167,7 +167,7 @@ export async function applyApp(root: string, slug: string, call: Call, provides:
167
167
  // ── A Custom App's code, in the project ─────────────────────────────────────
168
168
 
169
169
  /** Folders an App's code is never in: dependencies, build output, the project's state and its other kinds. */
170
- const NOT_APP_DIRS = new Set(['node_modules', '.git', '.frontera', '.next', 'dist', 'out', 'build', 'vendor', 'fixtures', 'datasets', 'blueprint', 'agents', 'workflows', 'functions', 'knowledge', 'skills', 'themes', 'mcp', 'chat-apps'])
170
+ const NOT_APP_DIRS = new Set(['node_modules', '.git', '.frontera', '.next', 'dist', 'out', 'build', 'vendor', 'fixtures', 'datasets', 'blueprint', 'agents', 'workflows', 'functions', 'knowledge', 'skills', 'themes', 'mcp', 'mcp-servers', 'chat-apps'])
171
171
 
172
172
  export function readPackage(dir: string): { scripts?: Record<string, string>; frontera?: unknown } | null {
173
173
  try {
@@ -473,7 +473,7 @@ export function itemKindsText(rows: BuildRow[]): string {
473
473
  objectType: ['object type', 'object types'], linkType: ['link type', 'link types'], metric: ['metric', 'metrics'], semanticType: ['semantic type', 'semantic types'],
474
474
  sharedField: ['shared field', 'shared fields'], objectSet: ['saved object set', 'saved object sets'], action: ['governed Action', 'governed Actions'],
475
475
  knowledge: ['knowledge base', 'knowledge bases'], skill: ['skill', 'skills'], plugin: ['Plugin', 'Plugins'], theme: ['slide theme', 'slide themes'],
476
- 'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'],
476
+ 'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'], 'mcp-server': ['MCP server', 'MCP servers'],
477
477
  }
478
478
  const counts = new Map<string, number>()
479
479
  for (const r of rows) {
@@ -27,6 +27,7 @@ import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
27
27
  * themes/<name>/theme.json (+ images) → the slide theme saved on the runtime (replacing its copy)
28
28
  * chat-apps/<slug>.json → the Chat App saved on the runtime (the owner's draft; published when it can be)
29
29
  * apps/<slug>.json → the Custom App made on the runtime, with the Functions it may start (not its code)
30
+ * mcp-servers/<slug>.yaml|.yml|.json → the workspace's MCP server of that slug, saved on the runtime (PUT /v1/mcp-servers/<slug>)
30
31
  * brand/brand.json (+ logo) → the organization's colors and logo on the runtime
31
32
  * library.json → the library installations, checked against the runtime's library (not installed)
32
33
  *
@@ -53,9 +54,13 @@ export type SyncTarget =
53
54
  | { kind: 'theme'; key: string; name: string; file: string }
54
55
  | { kind: 'chat-app'; key: string; name: string; file: string }
55
56
  | { kind: 'app'; key: string; name: string; file: string }
57
+ | { kind: 'mcp-server'; key: string; name: string; file: string }
56
58
  | { kind: 'brand'; key: string; file: string }
57
59
  | { kind: 'library'; key: string; file: string }
58
60
 
61
+ /** `mcp-servers/<slug>.yaml`: a slug as the service takes it (lowercase, digits, dashes; a letter first). */
62
+ const MCP_SERVER_FILE = /^[a-z][a-z0-9-]{0,62}\.(ya?ml|json)$/
63
+
59
64
  const IGNORED_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'fixtures'])
60
65
 
61
66
  /** Hidden files and editor leftovers: `.#x.yaml` locks, `#x#` autosaves, `x~` backups, `.swp`. */
@@ -122,6 +127,11 @@ export function targetFor(root: string, absolute: string): SyncTarget | null {
122
127
  const name = second.slice(0, -'.json'.length)
123
128
  return { kind: 'app', key: `app:${name}`, name, file: rel }
124
129
  }
130
+ // A workspace MCP server: its file's name is its slug (the last segment of its URL).
131
+ if (top === 'mcp-servers' && parts.length === 2 && second && MCP_SERVER_FILE.test(second)) {
132
+ const name = second.replace(/\.(ya?ml|json)$/, '')
133
+ return { kind: 'mcp-server', key: `mcp-server:${name}`, name, file: rel }
134
+ }
125
135
  // The brand is its file and the logo beside it.
126
136
  if (top === 'brand' && parts.length === 2 && existsSync(join(root, 'brand', 'brand.json'))) return { kind: 'brand', key: 'brand', file: join('brand', 'brand.json') }
127
137
  if (rel === 'library.json') return { kind: 'library', key: 'library', file: 'library.json' }
@@ -219,7 +229,7 @@ export function allTargets(root: string): SyncTarget[] {
219
229
  }
220
230
  }
221
231
  }
222
- for (const top of ['datasets', 'blueprint', 'knowledge', 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'brand']) walk(join(root, top), 1)
232
+ for (const top of ['datasets', 'blueprint', 'knowledge', 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'mcp-servers', 'brand']) walk(join(root, top), 1)
223
233
  if (existsSync(join(root, 'library.json'))) out.set('library', { kind: 'library', key: 'library', file: 'library.json' })
224
234
  // Datasets first (object types read them), then the Blueprint (Agents and
225
235
  // workflows read its objects), then what Agents use by name (knowledge
@@ -227,11 +237,12 @@ export function allTargets(root: string): SyncTarget[] {
227
237
  // Agents call them), then Agents (a workflow's agent step names one), then workflows, then what
228
238
  // each Agent may start, then the Agents' behavior settings (a procedure runs
229
239
  // only what its Agent may start), then the Chat Apps their Agents are used in and the Apps that
230
- // start Functions. The brand first of all (it is how everything looks), the library record last.
240
+ // start Functions, and the MCP servers whose tools name Functions, workflows, Actions and object types.
241
+ // The brand first of all (it is how everything looks), the library record last.
231
242
  return [...out.values()].sort(byApplyOrder)
232
243
  }
233
244
 
234
- const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, theme: 2, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, library: 11 }
245
+ const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, theme: 2, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, 'mcp-server': 10, library: 11 }
235
246
 
236
247
  /** Dependencies first: what a later kind reads or calls is applied before it. */
237
248
  export function byApplyOrder(a: SyncTarget, b: SyncTarget): number {
@@ -291,6 +302,7 @@ export function commandFor(target: SyncTarget, profiles: SyncProfiles): string[]
291
302
  case 'theme':
292
303
  case 'chat-app':
293
304
  case 'app':
305
+ case 'mcp-server':
294
306
  case 'brand':
295
307
  case 'library':
296
308
  case 'lessons':
@@ -435,6 +447,8 @@ export function describeTarget(target: SyncTarget): string {
435
447
  return `Chat App ${target.name}`
436
448
  case 'app':
437
449
  return `App ${target.name}`
450
+ case 'mcp-server':
451
+ return `MCP server ${target.name}`
438
452
  case 'brand':
439
453
  return 'the organization’s brand'
440
454
  case 'library':
@@ -496,6 +510,10 @@ export function unappliedReason(root: string, rel: string): string | null {
496
510
  return `skills/${second}/ has no SKILL.md, so it is not pushed`
497
511
  case 'chat-apps':
498
512
  return 'a Chat App is chat-apps/<slug>.json, directly in chat-apps/'
513
+ case 'mcp-servers':
514
+ if (parts.length > 2) return 'an MCP server is mcp-servers/<slug>.yaml, directly in mcp-servers/ (`frontera local schema mcp-server`)'
515
+ if (!/\.(ya?ml|json)$/.test(name)) return 'an MCP server is mcp-servers/<slug>.yaml (or .yml, .json)'
516
+ return `an MCP server's file is named by its slug: lowercase letters, digits and dashes, starting with a letter (mcp-servers/${name.replace(/\.(ya?ml|json)$/, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^[^a-z]+/, '') || 'my-server'}.yaml)`
499
517
  case 'mcp':
500
518
  return 'a Plugin is mcp/<name>.plugin.json (a stand-in mcp/<name>.stand-in.json)'
501
519
  default:
@@ -544,6 +562,6 @@ export function unappliedFiles(root: string): string[] {
544
562
  }
545
563
  }
546
564
  }
547
- for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
565
+ for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'mcp-servers', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
548
566
  return out
549
567
  }
@@ -14,7 +14,7 @@ import { join, posix } from 'node:path'
14
14
  */
15
15
 
16
16
  /** Top folders whose files are items of their own (or tooling), never a Function's shared file. */
17
- const ITEM_FOLDERS = new Set(['agents', 'workflows', 'skills', 'knowledge', 'blueprint', 'mcp', 'chat-apps', 'themes', 'datasets', 'apps', 'brand', 'functions', 'vendor', 'node_modules', 'dist'])
17
+ const ITEM_FOLDERS = new Set(['agents', 'workflows', 'skills', 'knowledge', 'blueprint', 'mcp', 'mcp-servers', 'chat-apps', 'themes', 'datasets', 'apps', 'brand', 'functions', 'vendor', 'node_modules', 'dist'])
18
18
 
19
19
  /** Project files that are the project's own, never a Function's. */
20
20
  const PROJECT_FILES = new Set(['frontera.yaml', 'frontera.lock', 'package.json', 'tsconfig.json', 'bun.lock', 'bun.lockb', 'library.json'])
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ import { errorOf } from './local-chat-apps'
6
+
7
+ /**
8
+ * A workspace's own MCP servers: `mcp-servers/<slug>.yaml` (or .yml, .json),
9
+ * one server per file, named by its slug. `local dev` saves each on the local
10
+ * runtime (`PUT /v1/mcp-servers/<slug>`, as the runtime's workspace key), and
11
+ * the service checks every tool against what the runtime holds: the object
12
+ * types the workspace is granted, its Functions, workflows and governed
13
+ * Actions. A server whose file is deleted is archived on the runtime.
14
+ *
15
+ * Its format is the service's (`frontera local schema mcp-server`).
16
+ */
17
+
18
+ type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
19
+
20
+ export const mcpServerPath = (slug: string, ext = 'yaml') => `mcp-servers/${slug}.${ext}`
21
+
22
+ export interface McpServerApplied {
23
+ ok: boolean
24
+ output: string
25
+ }
26
+
27
+ interface SavedServer {
28
+ server?: { slug?: string; name?: string; url?: string; tools?: unknown[] }
29
+ created?: boolean
30
+ warnings?: Array<{ name?: string; reason?: string }>
31
+ }
32
+
33
+ /** The file as the service takes it: YAML or JSON, a mapping at the top. */
34
+ export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; output: string } {
35
+ const path = join(root, file)
36
+ if (!existsSync(path)) return { ok: false, output: `error [MCP_SERVER]: ${file} is missing` }
37
+ let doc: unknown
38
+ try {
39
+ const text = readFileSync(path, 'utf8')
40
+ doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
41
+ } catch (err) {
42
+ return { ok: false, output: `error [MCP_SERVER]: ${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
43
+ }
44
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
45
+ return { ok: false, output: `error [MCP_SERVER]: ${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
46
+ }
47
+ return { ok: true, doc: doc as Record<string, unknown> }
48
+ }
49
+
50
+ /** Save one server on the runtime; the line says where a client connects, or what the service refused. */
51
+ export async function applyMcpServer(root: string, slug: string, file: string, call: Call): Promise<McpServerApplied> {
52
+ const read = readMcpServerFile(root, file)
53
+ if (!read.ok) return read
54
+ const res = await call('PUT', `/v1/mcp-servers/${encodeURIComponent(slug)}`, read.doc)
55
+ if (res.status !== 200 && res.status !== 201) {
56
+ return { ok: false, output: `error [MCP_SERVER]: MCP server ${slug} was refused: ${errorOf(res)}` }
57
+ }
58
+ const saved = ((res.body as { data?: unknown } | null)?.data ?? res.body) as SavedServer | null
59
+ const tools = saved?.server?.tools?.length ?? 0
60
+ const url = saved?.server?.url
61
+ const lines = [` ${saved?.created ? 'created' : 'applied'} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}` : ''}`]
62
+ for (const warning of saved?.warnings ?? []) {
63
+ lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
64
+ }
65
+ return { ok: true, output: lines.join('\n') }
66
+ }
@@ -88,7 +88,7 @@ export function writeNewFiles(root: string, files: Record<string, string>): stri
88
88
  // ── fmt ─────────────────────────────────────────────────────────────────────
89
89
 
90
90
  /** The folders whose JSON and YAML are project files; an App's own code and the tool state are left alone. */
91
- const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp']
91
+ const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp', 'mcp-servers']
92
92
  const FORMATTED_ROOT_FILES = ['frontera.yaml']
93
93
  const FORMATTED = /\.(json|ya?ml)$/
94
94
 
@@ -4,7 +4,7 @@ import type { LocalCredentials } from './local-state'
4
4
 
5
5
  /**
6
6
  * An Agent's behavior settings (`agents/<slug>/operating.json`: harness,
7
- * procedures, variables, focus, reply checks) staged onto its draft.
7
+ * procedures, variables, focus, reply checks, handover reasons) staged onto its draft.
8
8
  *
9
9
  * There is no CLI command for these: the route takes a person's session, not
10
10
  * an API key. The local runtime has the owner's session, as Studio does, so
@@ -31,6 +31,26 @@ interface Issue {
31
31
  message?: string
32
32
  }
33
33
 
34
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
35
+
36
+ /**
37
+ * The keys of the handover reasons an Agent has while its operating.json sets
38
+ * none, as the service's `DEFAULT_HANDOVER_REASONS` (`handover-reasons.ts`)
39
+ * holds them: what a handoff action's `handoverReason` may name then.
40
+ */
41
+ export const DEFAULT_HANDOVER_REASON_KEYS = ['asked_for_person', 'cannot_answer', 'needs_action', 'other'] as const
42
+
43
+ /** The reason keys a saved configuration offers: its own, or the defaults. */
44
+ function reasonKeys(config: unknown): string[] {
45
+ const own = isRecord(config) && Array.isArray(config.handoverReasons) ? (config.handoverReasons as unknown[]).flatMap((r) => (isRecord(r) && typeof r.key === 'string' ? [r.key] : [])) : []
46
+ return own.length ? own : [...DEFAULT_HANDOVER_REASON_KEYS]
47
+ }
48
+
49
+ /** What to do about a refused `handoverReasons` list, said once after its problems. */
50
+ function handoverReasonsFix(slug: string): string {
51
+ return ` Fix: in agents/${slug}/operating.json give "handoverReasons" 1 to 15 reasons, each { "key", "name", "whenToUse" } with a unique lower_snake_case key (at most 40 characters), a name of 1 to 60 characters and whenToUse of 1 to 300, one of them with the key "other"; or leave "handoverReasons" out for the default reasons. \`frontera local schema operating\` gives the format`
52
+ }
53
+
34
54
  /**
35
55
  * The issues a saved draft still has, one `warning:` line each, named by the
36
56
  * project file and the setting's path
@@ -39,31 +59,104 @@ interface Issue {
39
59
  * with the warnings and refuses publishing, so it is said too, as that.
40
60
  */
41
61
  export function operatingIssueLines(slug: string, body: unknown): string[] {
42
- const data = ((body as { data?: unknown } | null)?.data ?? body) as { issues?: unknown } | null
62
+ const data = ((body as { data?: unknown } | null)?.data ?? body) as { issues?: unknown; draft?: unknown } | null
43
63
  const issues = Array.isArray(data?.issues) ? (data.issues as Issue[]) : []
44
64
  return issues.map((issue) => {
45
65
  const path = typeof issue.path === 'string' ? issue.path : ''
46
66
  // The Agent's instructions are its agent.json prompts, not its behavior settings.
47
67
  const file = path === 'instructions' || path.startsWith('instructions.') ? `agents/${slug}/agent.json` : `agents/${slug}/operating.json`
48
68
  const blocks = issue.severity === 'error' ? ' (publishing is refused until this is fixed)' : ''
49
- return ` warning: ${file}${path ? ` ${path}` : ''}: ${issue.message ?? issue.code ?? 'see the Agent’s settings'}${blocks}`
69
+ // A handoff action naming a reason the Agent doesn't have: say which keys it has.
70
+ const fix = issue.code === 'unknown_handover_reason'
71
+ ? `; set "handoverReason" to one of ${reasonKeys(data?.draft).map((k) => `"${k}"`).join(', ')}, or add the reason to "handoverReasons"`
72
+ : ''
73
+ return ` warning: ${file}${path ? ` ${path}` : ''}: ${issue.message ?? issue.code ?? 'see the Agent’s settings'}${fix}${blocks}`
50
74
  })
51
75
  }
52
76
 
53
- function describeError(status: number, body: unknown): string {
77
+ function describeError(status: number, body: unknown, slug?: string): string {
54
78
  const b = (body ?? {}) as { message?: string; code?: string; details?: unknown; issues?: unknown }
55
79
  const lines = [`error [${b.code ?? status}]: ${b.message ?? 'the behavior settings were refused'}`]
56
80
  const issues = (b.details as { issues?: unknown } | undefined)?.issues ?? b.issues ?? b.details
57
81
  if (Array.isArray(issues)) {
58
- for (const issue of issues.slice(0, 6)) {
59
- const i = issue as { path?: unknown; message?: string }
60
- const path = Array.isArray(i.path) ? i.path.join('.') : typeof i.path === 'string' ? i.path : ''
61
- lines.push(` ${path ? `${path}: ` : ''}${i.message ?? JSON.stringify(issue)}`)
62
- }
82
+ const paths = issues.map((issue) => {
83
+ const i = issue as { path?: unknown }
84
+ return Array.isArray(i.path) ? i.path.join('.') : typeof i.path === 'string' ? i.path : ''
85
+ })
86
+ issues.slice(0, 6).forEach((issue, n) => {
87
+ const path = paths[n]!
88
+ lines.push(` ${path ? `${path}: ` : ''}${(issue as { message?: string }).message ?? JSON.stringify(issue)}`)
89
+ })
90
+ // The rules of the handover reasons, said once: the service's words name one problem each, not the whole rule.
91
+ if (slug && paths.some((p) => p === 'handoverReasons' || p.startsWith('handoverReasons.'))) lines.push(handoverReasonsFix(slug))
63
92
  }
64
93
  return lines.join('\n')
65
94
  }
66
95
 
96
+ /**
97
+ * The settings the file has that the saved draft does not: a runtime on a
98
+ * release older than the file's format keeps only the keys it knows and
99
+ * drops the rest without an error. Each is a warning that says it is left
100
+ * out here and how to run the release that has it. The keys checked are the
101
+ * top-level settings and each action's `handoverReason`; nothing is said when
102
+ * the answer carries no draft to compare.
103
+ */
104
+ export function droppedSettingLines(slug: string, sent: unknown, saved: unknown): string[] {
105
+ if (!isRecord(sent) || !isRecord(saved)) return []
106
+ const file = `agents/${slug}/operating.json`
107
+ const next = 'to use it here, run the runtime on the current release: `frontera local stop`, then `frontera local start`'
108
+ // Settings the service retired and drops on purpose (`dropRetiredKeys`).
109
+ const retired = new Set(['audience', 'conversation'])
110
+ const out = Object.keys(sent)
111
+ // `$schema` and other editor keys are not settings.
112
+ .filter((key) => !key.startsWith('$') && !retired.has(key) && !(key in saved))
113
+ .map((key) => key === 'handoverReasons'
114
+ ? ` warning: ${file} handoverReasons: this runtime's release has no handover reasons, so it left them out: here the Agent hands over without them; they still apply where the Agent runs on a release that has them. To use them here, run the runtime on the current release: \`frontera local stop\`, then \`frontera local start\``
115
+ : ` warning: ${file} ${key}: this runtime's format has no \`${key}\`, so it is left out here: check the name against \`frontera local schema operating\`, or, for a setting of a newer release, ${next}`)
116
+ const sentProcedures = Array.isArray(sent.procedures) ? sent.procedures : []
117
+ const savedProcedures = Array.isArray(saved.procedures) ? saved.procedures : []
118
+ sentProcedures.forEach((p, i) => {
119
+ const kept = savedProcedures[i]
120
+ if (!isRecord(p) || !isRecord(kept)) return
121
+ if (Array.isArray(p.actions) && Array.isArray(kept.actions)) {
122
+ p.actions.forEach((a, j) => {
123
+ const k = (kept.actions as unknown[])[j]
124
+ if (isRecord(a) && typeof a.handoverReason === 'string' && isRecord(k) && !('handoverReason' in k)) {
125
+ out.push(` warning: ${file} procedures.${i}.actions.${j}.handoverReason: this runtime's release has no handover reasons, so the action hands over without "${a.handoverReason}" here; ${next}`)
126
+ }
127
+ })
128
+ }
129
+ // An End step that hands over, wherever it sits in the procedure's steps.
130
+ const endSteps = (sentSteps: unknown, keptSteps: unknown, path: string) => {
131
+ if (!Array.isArray(sentSteps) || !Array.isArray(keptSteps)) return
132
+ sentSteps.forEach((s, j) => {
133
+ const k = keptSteps[j]
134
+ if (!isRecord(s) || !isRecord(k)) return
135
+ const at = `${path}.${j}`
136
+ if (s.type === 'end' && typeof s.handoverReason === 'string' && !('handoverReason' in k)) {
137
+ out.push(` warning: ${file} ${at}.handoverReason: this runtime's release has no handover reasons, so the step hands over without "${s.handoverReason}" here; ${next}`)
138
+ }
139
+ endSteps(s.onFailure, k.onFailure, `${at}.onFailure`)
140
+ endSteps(s.otherwise, k.otherwise, `${at}.otherwise`)
141
+ if (Array.isArray(s.arms) && Array.isArray(k.arms)) {
142
+ s.arms.forEach((arm, ai) => {
143
+ const keptArm = (k.arms as unknown[])[ai]
144
+ if (isRecord(arm) && isRecord(keptArm)) endSteps(arm.steps, keptArm.steps, `${at}.arms.${ai}.steps`)
145
+ })
146
+ }
147
+ })
148
+ }
149
+ endSteps(p.steps, kept.steps, `procedures.${i}.steps`)
150
+ if (Array.isArray(p.blocks) && Array.isArray(kept.blocks)) {
151
+ p.blocks.forEach((b, bi) => {
152
+ const keptBlock = (kept.blocks as unknown[])[bi]
153
+ if (isRecord(b) && isRecord(keptBlock)) endSteps(b.steps, keptBlock.steps, `procedures.${i}.blocks.${bi}.steps`)
154
+ })
155
+ }
156
+ })
157
+ return out
158
+ }
159
+
67
160
  export async function applyOperatingConfig(args: {
68
161
  serviceUrl: string
69
162
  creds: LocalCredentials
@@ -111,9 +204,11 @@ export async function applyOperatingConfig(args: {
111
204
  }
112
205
  if (agent.status !== 200 || !id) return { ok: false, output: describeError(agent.status, agent.body) }
113
206
  const put = await call(`/v1/config/agents/${id}/operating`, { method: 'PUT', body: { operatingConfig } })
114
- if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body) }
207
+ if (put.status >= 400) return { ok: false, output: describeError(put.status, put.body, args.slug) }
115
208
  const warnings = operatingIssueLines(args.slug, put.body)
116
- return { ok: true, output: warnings.join('\n'), ...(warnings.length ? { validationWarnings: warnings } : {}) }
209
+ // What this runtime's release left out is an adaptation to it, not a finding on the file.
210
+ const dropped = droppedSettingLines(args.slug, operatingConfig, (put.body as { data?: { draft?: unknown } } | null)?.data?.draft)
211
+ return { ok: true, output: [...warnings, ...dropped].join('\n'), ...(warnings.length ? { validationWarnings: warnings } : {}) }
117
212
  } catch (err) {
118
213
  return { ok: false, output: `error [UNAVAILABLE]: the local service did not answer: ${(err as Error).message}` }
119
214
  }