@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 +4 -4
- package/src/api/platform-api.ts +16 -0
- package/src/commands/local/dev.ts +7 -1
- package/src/commands/mcp-server/index-commands.ts +96 -0
- package/src/commands/registry.ts +3 -0
- package/src/local/local-applied.ts +3 -0
- package/src/local/local-apps.ts +1 -1
- package/src/local/local-build-summary.ts +1 -1
- package/src/local/local-dev-sync.ts +22 -4
- package/src/local/local-function-shared.ts +1 -1
- package/src/local/local-mcp-servers.ts +66 -0
- package/src/local/local-new.ts +1 -1
- package/src/local/local-operating.ts +106 -11
- package/src/local/local-orphans.ts +29 -4
- package/src/local/local-scaffold.ts +2 -2
- package/src/local/local-schemas.ts +25 -1
- package/src/mcp/tools/docs-tools.ts +1 -1
- package/src/vendor/sdk-sources.json +17 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
44
|
-
"@frontera-sdk/functions": "1.
|
|
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
|
+
"@frontera-sdk/forge-contracts": "1.53.1",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -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]
|
package/src/commands/registry.ts
CHANGED
|
@@ -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". */
|
package/src/local/local-apps.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/local/local-new.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
const i = issue as { path?: unknown
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
}
|