@frontera-sdk/cli 1.52.27 → 1.52.29
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 +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse as parseYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { answerDocsMessage } from './local-docs'
|
|
7
|
+
import { HOST_FROM_CONTAINER } from './local-env'
|
|
8
|
+
import type { LocalProject } from './local-project'
|
|
9
|
+
import { readState } from './local-state'
|
|
10
|
+
import type { SyncClient } from './local-sync-client'
|
|
11
|
+
import type { RemoteItem } from './local-sync-plan'
|
|
12
|
+
import { rows } from './local-sync-remote'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stand-ins for built-in connections (design §8, class Stubbed).
|
|
16
|
+
*
|
|
17
|
+
* A built-in connection (Google Sheets, BigQuery, GitHub…) is signed in on
|
|
18
|
+
* the platform with OAuth or a service account, so the local runtime cannot
|
|
19
|
+
* reach it. An Agent, a procedure, a workflow step or trigger that names one
|
|
20
|
+
* would otherwise be refused locally, or applied without it. Instead the
|
|
21
|
+
* pull declares a stand-in, `mcp/<name>.stand-in.json` (the connection's name
|
|
22
|
+
* and tools, as the platform lists them), and `local dev` installs it as a
|
|
23
|
+
* Plugin of that name, served by the runtime's own Studio server: it lists
|
|
24
|
+
* the connection's tools, and each answers "not connected" in words. So the
|
|
25
|
+
* Agent, the procedure and the workflow apply as written, and what they do
|
|
26
|
+
* with the connection fails visibly instead of silently.
|
|
27
|
+
*
|
|
28
|
+
* A stand-in is the project's declaration, never a Plugin of the workspace:
|
|
29
|
+
* a change request never proposes it (it is not a `.plugin.json`), and a
|
|
30
|
+
* real `mcp/<name>.plugin.json` takes its place.
|
|
31
|
+
*
|
|
32
|
+
* Two of the workspace's built-in Plugins are not connections to someone
|
|
33
|
+
* else's system, and are declared apart:
|
|
34
|
+
* - **Documentation** (Frontera's documentation for Agents): its stand-in
|
|
35
|
+
* `serves: "documentation"`, answering the plugin's tools from the
|
|
36
|
+
* documentation `local start` copied for the runtime's release (the same
|
|
37
|
+
* copy `frontera local docs-mcp` serves the project's coding agents). The
|
|
38
|
+
* Agent's documentation tools work here.
|
|
39
|
+
* - **Analytics**: the workspace's analytics tools read its analytics
|
|
40
|
+
* project, which a local runtime does not have (design §8). The pull
|
|
41
|
+
* declares it platform-only, `mcp/<name>.platform.json`. An Agent that
|
|
42
|
+
* uses it runs here without those tools, said in the build as "Analytics
|
|
43
|
+
* tools stay on the platform". So that a workflow step on it still
|
|
44
|
+
* applies, `local dev` installs a Plugin of its name served by the Studio
|
|
45
|
+
* server, as for a connection's stand-in, whose every tool answers in
|
|
46
|
+
* words that the analytics tools stay on the platform.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export const standInPath = (name: string) => `mcp/${name}.stand-in.json`
|
|
50
|
+
|
|
51
|
+
export interface StandInFile {
|
|
52
|
+
standIn: true
|
|
53
|
+
/** The connection as people know it: "Google Sheets". */
|
|
54
|
+
connection: string
|
|
55
|
+
/** `documentation`: it serves the runtime's documentation instead of answering "not connected". */
|
|
56
|
+
serves?: 'documentation'
|
|
57
|
+
note: string
|
|
58
|
+
tools: Array<{ name: string; description?: string }>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NOTE = 'A stand-in for a connection signed in on the platform. On the local runtime its tools answer “not connected”. Frontera writes this file from the workspace; a change request never proposes it.'
|
|
62
|
+
const DOCS_NOTE = 'Frontera’s documentation for Agents. On the local runtime it serves the documentation of the runtime’s release. Frontera writes this file from the workspace; a change request never proposes it.'
|
|
63
|
+
|
|
64
|
+
/** The Documentation plugin's tools, as the platform's documentation server lists them. */
|
|
65
|
+
const DOCS_TOOLS: StandInFile['tools'] = [
|
|
66
|
+
{ name: 'get_documentation_context', description: 'The documentation release this runtime serves' },
|
|
67
|
+
{ name: 'read_documentation', description: 'Read a guide' },
|
|
68
|
+
{ name: 'search_documentation', description: 'Search the documentation' },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/** The built-in Plugins these items need that get a stand-in (connections, the Documentation plugin), by install name. */
|
|
72
|
+
export function connectionsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
|
|
73
|
+
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Which of those are the Documentation plugin. */
|
|
77
|
+
export function documentationNeeded(items: Array<Pick<RemoteItem, 'portability'>>): Set<string> {
|
|
78
|
+
return new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'documentation' ? [n.name] : []))))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The workspace's analytics Plugins these items use: platform-only. */
|
|
82
|
+
export function analyticsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
|
|
83
|
+
return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Platform-only Plugins ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export const platformOnlyPath = (name: string) => `mcp/${name}.platform.json`
|
|
89
|
+
|
|
90
|
+
/** What an Agent that uses the workspace's analytics is told, in the build and the Pull dialog. */
|
|
91
|
+
export const ANALYTICS_STAY = 'Analytics tools stay on the platform: a local runtime has no analytics'
|
|
92
|
+
|
|
93
|
+
/** What each of the analytics Plugin's tools answers on the local runtime. */
|
|
94
|
+
export function analyticsStay(tool: string): string {
|
|
95
|
+
return `Analytics tools stay on the platform: a local runtime has no analytics, so ${tool} didn’t run. Run this in the workspace.`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the project declares this Plugin platform-only (`mcp/<name>.platform.json`). */
|
|
99
|
+
export function isPlatformOnly(root: string, name: string): boolean {
|
|
100
|
+
return existsSync(join(root, platformOnlyPath(name)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PlatformOnlyFile {
|
|
104
|
+
platformOnly: 'analytics'
|
|
105
|
+
note: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ANALYTICS_NOTE = 'The workspace’s analytics tools. They stay on the platform: they read the workspace’s analytics, which a local runtime does not have. Frontera writes this file from the workspace; nothing is installed locally, and a change request never proposes it.'
|
|
109
|
+
|
|
110
|
+
/** The Plugins the project declares platform-only, by install name. */
|
|
111
|
+
export function platformOnlyNames(root: string): Set<string> {
|
|
112
|
+
try {
|
|
113
|
+
return new Set(readdirSync(join(root, 'mcp')).flatMap((f) => (f.endsWith('.platform.json') && f !== '.platform.json' ? [f.slice(0, -'.platform.json'.length)] : [])))
|
|
114
|
+
} catch {
|
|
115
|
+
return new Set()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Declare each analytics Plugin the project's items use as platform-only. Returns the paths written. */
|
|
120
|
+
export function writePlatformOnly(root: string, names: string[]): string[] {
|
|
121
|
+
const out: string[] = []
|
|
122
|
+
const doc: PlatformOnlyFile = { platformOnly: 'analytics', note: ANALYTICS_NOTE }
|
|
123
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
const path = join(root, platformOnlyPath(name))
|
|
126
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) continue
|
|
127
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
128
|
+
writeFileSync(path, text)
|
|
129
|
+
out.push(platformOnlyPath(name))
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* An Agent's rows that name a platform-only Plugin (its install, its MCP
|
|
136
|
+
* server, its tool settings) are left off its local copy, said once.
|
|
137
|
+
* Changes `doc`; returns the warning.
|
|
138
|
+
*/
|
|
139
|
+
export function dropPlatformOnly(doc: Record<string, unknown>, names: ReadonlySet<string>): string[] {
|
|
140
|
+
if (!names.size) return []
|
|
141
|
+
const named = (v: unknown) => typeof v === 'string' && v.startsWith('plugin/') && names.has(v.slice('plugin/'.length))
|
|
142
|
+
let dropped = false
|
|
143
|
+
const keep = (key: string, test: (row: Record<string, unknown>) => boolean) => {
|
|
144
|
+
if (!Array.isArray(doc[key])) return
|
|
145
|
+
const rows = doc[key] as Array<Record<string, unknown>>
|
|
146
|
+
const kept = rows.filter((r) => !test(r ?? {}))
|
|
147
|
+
if (kept.length !== rows.length) dropped = true
|
|
148
|
+
doc[key] = kept
|
|
149
|
+
}
|
|
150
|
+
keep('appInstalls', (r) => named(r.installId))
|
|
151
|
+
keep('mcpServers', (r) => typeof r.name === 'string' && names.has(r.name))
|
|
152
|
+
keep('pluginTools', (r) => named(r.plugin))
|
|
153
|
+
return dropped ? [` warning: ${ANALYTICS_STAY}`] : []
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readStandIn(root: string, name: string): StandInFile | null {
|
|
157
|
+
try {
|
|
158
|
+
const doc = JSON.parse(readFileSync(join(root, standInPath(name)), 'utf8')) as StandInFile
|
|
159
|
+
return doc && doc.standIn === true ? doc : null
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The stand-ins the project declares, by connection name. */
|
|
166
|
+
export function standInNames(root: string): Set<string> {
|
|
167
|
+
try {
|
|
168
|
+
return new Set(readdirSync(join(root, 'mcp')).flatMap((f) => (f.endsWith('.stand-in.json') && f !== '.stand-in.json' ? [f.slice(0, -'.stand-in.json'.length)] : [])))
|
|
169
|
+
} catch {
|
|
170
|
+
return new Set()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface AppRow {
|
|
175
|
+
install?: { id: string; installName: string }
|
|
176
|
+
catalog?: { kind?: string; name?: string; displayName?: string }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface StandInsWritten {
|
|
180
|
+
/** Declared now, or brought up to date with the workspace's tools. */
|
|
181
|
+
written: string[]
|
|
182
|
+
/** A real Plugin file of that name is in the project: it is used instead. */
|
|
183
|
+
replaced: string[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Declare a stand-in for each connection the project's items need: its name
|
|
188
|
+
* and tools from the workspace, when the platform can be read; otherwise its
|
|
189
|
+
* name alone (the tools the project's files name still answer). An existing
|
|
190
|
+
* stand-in is rewritten only to follow the workspace.
|
|
191
|
+
*/
|
|
192
|
+
export async function writeStandIns(root: string, names: string[], ws?: SyncClient, documentation: ReadonlySet<string> = new Set()): Promise<StandInsWritten> {
|
|
193
|
+
const out: StandInsWritten = { written: [], replaced: [] }
|
|
194
|
+
if (!names.length) return out
|
|
195
|
+
const apps = ws ? await ws.get<unknown>('/v1/apps').then((b) => rows<AppRow>(b)).catch(() => [] as AppRow[]) : []
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
if (existsSync(join(root, 'mcp', `${name}.plugin.json`))) {
|
|
198
|
+
out.replaced.push(name)
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
const row = apps.find((a) => a.install?.installName === name)
|
|
202
|
+
let tools: StandInFile['tools'] | null = null
|
|
203
|
+
if (ws && row?.install) {
|
|
204
|
+
tools = await ws
|
|
205
|
+
.get<unknown>(`/v1/apps/${encodeURIComponent(row.install.id)}/capabilities`)
|
|
206
|
+
.then((b) => rows<{ name?: string; displayName?: string; description?: string | null }>(b).flatMap((t) => (t.name ? [{ name: t.name, ...(t.description || t.displayName ? { description: String(t.description || t.displayName) } : {}) }] : [])))
|
|
207
|
+
.catch(() => null)
|
|
208
|
+
}
|
|
209
|
+
const before = readStandIn(root, name)
|
|
210
|
+
const docs = documentation.has(name) || before?.serves === 'documentation'
|
|
211
|
+
const doc: StandInFile = {
|
|
212
|
+
standIn: true,
|
|
213
|
+
connection: row?.catalog?.displayName ?? before?.connection ?? name,
|
|
214
|
+
...(docs ? { serves: 'documentation' as const } : {}),
|
|
215
|
+
note: docs ? DOCS_NOTE : NOTE,
|
|
216
|
+
tools: (tools?.length ? tools : before?.tools?.length ? before.tools : docs ? DOCS_TOOLS : []).sort((a, b) => a.name.localeCompare(b.name)),
|
|
217
|
+
}
|
|
218
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`
|
|
219
|
+
const path = join(root, standInPath(name))
|
|
220
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) continue
|
|
221
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
222
|
+
writeFileSync(path, text)
|
|
223
|
+
out.written.push(name)
|
|
224
|
+
}
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The tools a stand-in lists: the connection's, as the workspace listed
|
|
230
|
+
* them, and every tool the project's Agents and workflows name on it (a
|
|
231
|
+
* workflow step's `capability`, an Agent's tool setting), so each resolves.
|
|
232
|
+
*/
|
|
233
|
+
export function standInTools(root: string, name: string): StandInFile['tools'] {
|
|
234
|
+
const declared = readStandIn(root, name)?.tools ?? []
|
|
235
|
+
const tools = new Map(declared.map((t) => [t.name, t]))
|
|
236
|
+
const ref = `plugin/${name}`
|
|
237
|
+
const visit = (v: unknown) => {
|
|
238
|
+
if (Array.isArray(v)) return v.forEach(visit)
|
|
239
|
+
if (!v || typeof v !== 'object') return
|
|
240
|
+
const o = v as Record<string, unknown>
|
|
241
|
+
const names = o.resourceId === ref || o.plugin === ref || o.installId === ref
|
|
242
|
+
if (names) {
|
|
243
|
+
const params = (o.params ?? {}) as Record<string, unknown>
|
|
244
|
+
for (const tool of [o.capability, o.tool, params.capability]) if (typeof tool === 'string' && tool && !tools.has(tool)) tools.set(tool, { name: tool })
|
|
245
|
+
}
|
|
246
|
+
for (const x of Object.values(o)) visit(x)
|
|
247
|
+
}
|
|
248
|
+
for (const top of ['agents', 'workflows']) {
|
|
249
|
+
const walk = (dir: string, depth: number) => {
|
|
250
|
+
if (depth > 3 || !existsSync(dir)) return
|
|
251
|
+
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
|
252
|
+
if (f.name.startsWith('.')) continue
|
|
253
|
+
const path = join(dir, f.name)
|
|
254
|
+
if (f.isDirectory()) walk(path, depth + 1)
|
|
255
|
+
else if (/\.(json|ya?ml)$/.test(f.name)) {
|
|
256
|
+
try {
|
|
257
|
+
const text = readFileSync(path, 'utf8')
|
|
258
|
+
if (text.includes(ref)) visit(f.name.endsWith('.json') ? JSON.parse(text) : parseYaml(text))
|
|
259
|
+
} catch {
|
|
260
|
+
// A file that does not parse names nothing here.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
walk(join(root, top), 0)
|
|
266
|
+
}
|
|
267
|
+
return [...tools.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Where the runtime reaches a stand-in: the Studio server, from wherever the service runs. */
|
|
271
|
+
export function standInUrl(project: LocalProject, name: string): string | null {
|
|
272
|
+
const state = readState(project)
|
|
273
|
+
if (!state?.ports?.studio) return null
|
|
274
|
+
const host = state.mode === 'image' ? HOST_FROM_CONTAINER : '127.0.0.1'
|
|
275
|
+
return `http://${host}:${state.ports.studio}/__stand-in/${encodeURIComponent(name)}/mcp`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** What every one of a stand-in's tools answers. */
|
|
279
|
+
export function notConnected(connection: string, tool: string): string {
|
|
280
|
+
return `${connection} isn’t connected on this local runtime, so ${tool} didn’t run. ${connection} is signed in on the platform only: try this in the workspace, or use sample data here.`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A stand-in's MCP endpoint (streamable HTTP, JSON answers): it lists the
|
|
285
|
+
* connection's tools and answers every call with a "not connected" error. It holds
|
|
286
|
+
* nothing and reaches nothing, so it needs no session.
|
|
287
|
+
*/
|
|
288
|
+
export async function standInMcp(root: string, name: string, req: Request, docsPath?: string): Promise<Response> {
|
|
289
|
+
if (req.method !== 'POST') return new Response(null, { status: 405, headers: { allow: 'POST' } })
|
|
290
|
+
// The analytics Plugin, platform-only, is served the same way: each of its tools says it stays on the platform.
|
|
291
|
+
const platformOnly = isPlatformOnly(root, name) && !readStandIn(root, name)
|
|
292
|
+
const doc: StandInFile | null = platformOnly ? { standIn: true, connection: 'Analytics', note: '', tools: [] } : readStandIn(root, name)
|
|
293
|
+
if (!doc) return new Response('No such stand-in', { status: 404 })
|
|
294
|
+
let body: unknown
|
|
295
|
+
try {
|
|
296
|
+
body = await req.json()
|
|
297
|
+
} catch {
|
|
298
|
+
return Response.json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, { status: 400 })
|
|
299
|
+
}
|
|
300
|
+
const answer = (msg: { id?: unknown; method?: string; params?: Record<string, unknown> }) => {
|
|
301
|
+
if (msg.id === undefined || msg.id === null) return null
|
|
302
|
+
// The Documentation plugin: the runtime's documentation, with the tools the platform's has.
|
|
303
|
+
if (doc.serves === 'documentation') return answerDocsMessage(msg as Parameters<typeof answerDocsMessage>[0], docsPath ?? '', undefined, 'agent')
|
|
304
|
+
switch (msg.method) {
|
|
305
|
+
case 'initialize':
|
|
306
|
+
return { jsonrpc: '2.0', id: msg.id, result: { protocolVersion: typeof msg.params?.protocolVersion === 'string' ? msg.params.protocolVersion : '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: `${name} (stand-in)`, version: '1' }, instructions: platformOnly ? ANALYTICS_STAY : notConnected(doc.connection, 'its tools') } }
|
|
307
|
+
case 'ping':
|
|
308
|
+
return { jsonrpc: '2.0', id: msg.id, result: {} }
|
|
309
|
+
case 'tools/list':
|
|
310
|
+
return {
|
|
311
|
+
jsonrpc: '2.0',
|
|
312
|
+
id: msg.id,
|
|
313
|
+
result: { tools: standInTools(root, name).map((t) => ({ name: t.name, description: `${t.description ? `${t.description} ` : ''}(${platformOnly ? 'Analytics tools stay on the platform: a local runtime has no analytics.' : `Not connected on the local runtime: ${doc.connection} runs on the platform only.`})`, inputSchema: { type: 'object', additionalProperties: true } })) },
|
|
314
|
+
}
|
|
315
|
+
case 'tools/call': {
|
|
316
|
+
// An error, not a result marked isError: a workflow step takes a result as its output and
|
|
317
|
+
// goes on; an error stops the step with the words, and an Agent's tool call reports them.
|
|
318
|
+
const tool = typeof msg.params?.name === 'string' ? msg.params.name : 'this tool'
|
|
319
|
+
return { jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: platformOnly ? analyticsStay(tool) : notConnected(doc.connection, tool) } }
|
|
320
|
+
}
|
|
321
|
+
default:
|
|
322
|
+
return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const batch = Array.isArray(body)
|
|
326
|
+
const replies = (batch ? (body as unknown[]) : [body]).map((m) => answer((m ?? {}) as Parameters<typeof answer>[0])).filter(Boolean)
|
|
327
|
+
if (!replies.length) return new Response(null, { status: 202 })
|
|
328
|
+
return Response.json(batch ? replies : replies[0])
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseJson<T>(output: string): T | null {
|
|
332
|
+
const start = output.search(/[[{]/)
|
|
333
|
+
if (start < 0) return null
|
|
334
|
+
try {
|
|
335
|
+
return JSON.parse(output.slice(start)) as T
|
|
336
|
+
} catch {
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Why an item using a stand-in runs as it does, as its build says it. */
|
|
342
|
+
export function standInAdaptation(connection: string): string {
|
|
343
|
+
return `${connection} isn’t connected here: a stand-in answers its tools with “not connected”`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Install a stand-in on the runtime as a Plugin of the connection's name,
|
|
348
|
+
* pointed at its endpoint, every tool on: an Agent, procedure or workflow
|
|
349
|
+
* that names the connection then resolves, and each call says it is not
|
|
350
|
+
* connected.
|
|
351
|
+
*/
|
|
352
|
+
export async function applyStandIn(
|
|
353
|
+
project: LocalProject,
|
|
354
|
+
name: string,
|
|
355
|
+
workspaceProfile: string,
|
|
356
|
+
run: (args: string[]) => Promise<{ ok: boolean; output: string }>,
|
|
357
|
+
): Promise<{ ok: boolean; output: string }> {
|
|
358
|
+
const platformOnly = isPlatformOnly(project.root, name) && !readStandIn(project.root, name)
|
|
359
|
+
const doc: StandInFile | null = platformOnly ? { standIn: true, connection: 'Analytics', note: '', tools: [] } : readStandIn(project.root, name)
|
|
360
|
+
if (!doc) return { ok: false, output: `error [USAGE]: ${standInPath(name)} is not a stand-in` }
|
|
361
|
+
const made = standInPluginFile(project, name)
|
|
362
|
+
if (!made) return { ok: false, output: 'error [LOCAL_NOT_RUNNING]: the runtime has no Studio port for the stand-in' }
|
|
363
|
+
const p = ['--profile', workspaceProfile]
|
|
364
|
+
const find = async () => {
|
|
365
|
+
const listed = await run(['plugin', 'list', '--json', ...p])
|
|
366
|
+
return (parseJson<Array<{ install?: { id: string; installName: string } }>>(listed.output) ?? []).find((r) => r.install?.installName === name)?.install
|
|
367
|
+
}
|
|
368
|
+
let id = (await find())?.id
|
|
369
|
+
const step = id ? await run(['plugin', 'configure', id, '--file', made.file, ...p]) : await run(['plugin', 'add', 'mcp', '--name', name, '--file', made.file, ...p])
|
|
370
|
+
if (!step.ok) return step
|
|
371
|
+
id ??= (await find())?.id
|
|
372
|
+
if (!id) return { ok: false, output: `error [NOT_FOUND]: the stand-in ${name} was added but is not listed` }
|
|
373
|
+
const open = await run(['plugin', 'policy', id, '--enable', '--actions', 'allow_all', '--new-capability', 'enabled', ...p])
|
|
374
|
+
const lines = platformOnly
|
|
375
|
+
? [` analytics Plugin ${name}: ${standInTools(project.root, name).length} tools, each answering that analytics tools stay on the platform`, ` warning: ${ANALYTICS_STAY}`]
|
|
376
|
+
: doc.serves === 'documentation'
|
|
377
|
+
? [` ${doc.connection}: ${standInTools(project.root, name).length} tools, serving the documentation of this runtime’s release`]
|
|
378
|
+
: [` stand-in for ${doc.connection}: ${standInTools(project.root, name).length} tools, each answering “not connected”`, ` warning: ${standInAdaptation(doc.connection)}`]
|
|
379
|
+
if (!open.ok) lines.push(` warning: The stand-in ${name}’s tools could not be switched on: ${open.output.trim().split('\n')[0]}`)
|
|
380
|
+
return { ok: true, output: lines.join('\n') }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A workflow's Plugin triggers on a connection the project has a stand-in
|
|
385
|
+
* for: a stand-in answers tools, not events, so the trigger is left out (the
|
|
386
|
+
* workflow still runs by hand, from an Agent, or from its other triggers).
|
|
387
|
+
* Changes `doc`; returns each as a warning.
|
|
388
|
+
*/
|
|
389
|
+
export function dropStandInTriggers(root: string, doc: unknown, names: ReadonlySet<string>): string[] {
|
|
390
|
+
const d = (doc ?? {}) as Record<string, unknown>
|
|
391
|
+
if (!names.size || !Array.isArray(d.trigger)) return []
|
|
392
|
+
const out: string[] = []
|
|
393
|
+
d.trigger = (d.trigger as Array<Record<string, unknown>>).filter((t) => {
|
|
394
|
+
const name = t?.kind === 'plugin' && typeof t.resourceId === 'string' && t.resourceId.startsWith('plugin/') ? t.resourceId.slice('plugin/'.length) : null
|
|
395
|
+
if (!name || !names.has(name)) return true
|
|
396
|
+
const event = typeof t.event === 'string' ? t.event : ''
|
|
397
|
+
if (isPlatformOnly(root, name) && !readStandIn(root, name)) {
|
|
398
|
+
out.push(` warning: Its Plugin trigger ${event} is left out here: ${ANALYTICS_STAY}`.replace('trigger is', 'trigger is'))
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
const connection = readStandIn(root, name)?.connection ?? name
|
|
402
|
+
out.push(` warning: Its Plugin trigger ${event} is left out here: ${connection} isn’t connected here, and its stand-in answers tools, not events`.replace('trigger is', 'trigger is'))
|
|
403
|
+
return false
|
|
404
|
+
})
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A local Agent is a draft, and a draft runs the MCP servers its own list
|
|
410
|
+
* names (`mcpServers`), not its Plugin installs: each connection it uses
|
|
411
|
+
* that has a stand-in gets the stand-in's server in that list. Changes `doc`.
|
|
412
|
+
*/
|
|
413
|
+
export function addStandInServers(project: LocalProject, doc: Record<string, unknown>, names: ReadonlySet<string>): void {
|
|
414
|
+
if (!names.size || !Array.isArray(doc.appInstalls)) return
|
|
415
|
+
const servers = Array.isArray(doc.mcpServers) ? (doc.mcpServers as Array<Record<string, unknown>>) : []
|
|
416
|
+
for (const row of doc.appInstalls as Array<Record<string, unknown>>) {
|
|
417
|
+
const name = typeof row?.installId === 'string' && row.installId.startsWith('plugin/') ? row.installId.slice('plugin/'.length) : null
|
|
418
|
+
if (!name || !names.has(name)) continue
|
|
419
|
+
const url = standInUrl(project, name)
|
|
420
|
+
if (!url) continue
|
|
421
|
+
// The workspace's own server for it (the Documentation plugin's, at the platform's address) points here instead.
|
|
422
|
+
const listed = servers.find((s) => s?.name === name)
|
|
423
|
+
if (listed) {
|
|
424
|
+
listed.transportType = 'http'
|
|
425
|
+
listed.url = url
|
|
426
|
+
delete listed.headers
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
servers.push({ name, transportType: 'http', url, enabled: row.enabled !== false, autoStage: false, toonCompression: false })
|
|
430
|
+
}
|
|
431
|
+
if (servers.length) doc.mcpServers = servers
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The Plugin file a stand-in is installed from: the runtime's own address for it, nothing secret. */
|
|
435
|
+
export function standInPluginFile(project: LocalProject, name: string): { file: string; url: string } | null {
|
|
436
|
+
const url = standInUrl(project, name)
|
|
437
|
+
if (!url) return null
|
|
438
|
+
const file = join('.frontera', 'local', 'resolved', 'mcp', `${name}.plugin.json`)
|
|
439
|
+
mkdirSync(join(project.root, dirname(file)), { recursive: true })
|
|
440
|
+
writeFileSync(join(project.root, file), `${JSON.stringify({ transport: 'http', url }, null, 2)}\n`)
|
|
441
|
+
return { file, url }
|
|
442
|
+
}
|
|
@@ -5,10 +5,14 @@ import { extname, join, normalize } from 'node:path'
|
|
|
5
5
|
import type { LocalProject } from './local-project'
|
|
6
6
|
import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
|
|
7
7
|
import { solutionStatus } from './local-applied'
|
|
8
|
+
import { projectBlueprintOutline } from './local-blueprint-outline'
|
|
9
|
+
import { itemBuild } from './local-build-summary'
|
|
8
10
|
import { logPath, readApplied, readState, type ComponentName } from './local-state'
|
|
9
11
|
import { appendTestCase, renderTestCase } from './local-test-cases'
|
|
10
12
|
import { findCaseFiles } from './local-test-runner'
|
|
11
13
|
import { createTestRuns } from './local-test-runs'
|
|
14
|
+
import { docsCopyPath } from './local-docs'
|
|
15
|
+
import { standInMcp } from './local-stand-ins'
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Frontera Studio's server: the static app, the API, and the runtime's own
|
|
@@ -18,7 +22,9 @@ import { createTestRuns } from './local-test-runs'
|
|
|
18
22
|
* Three rules shape it:
|
|
19
23
|
* - It binds to 127.0.0.1 only, and every request needs the session cookie
|
|
20
24
|
* that the printed `?token=` link sets. "No auth configured means public" is
|
|
21
|
-
* the pattern we are deliberately not copying.
|
|
25
|
+
* the pattern we are deliberately not copying. The one exception is a
|
|
26
|
+
* built-in connection's stand-in (`/__stand-in/<name>/mcp`), which the
|
|
27
|
+
* service calls: it lists tool names and answers "not connected", nothing else.
|
|
22
28
|
* - The browser never sees a credential. `/api/*` is proxied to the local
|
|
23
29
|
* service with the seeded owner's session added here, so the API explorer
|
|
24
30
|
* and every Studio view call the real API exactly as the platform does.
|
|
@@ -113,6 +119,16 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
113
119
|
if (url.pathname === '/__local/solution') {
|
|
114
120
|
return json({ items: solutionStatus(project, readState(project)?.credentials?.organizationId ?? null) })
|
|
115
121
|
}
|
|
122
|
+
// How the last `local dev` built one item (`?ref=agent/desk`), with each adaptation in words: the Agent page says it.
|
|
123
|
+
if (url.pathname === '/__local/build') {
|
|
124
|
+
const ref = url.searchParams.get('ref') ?? ''
|
|
125
|
+
if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
|
|
126
|
+
return json({ build: itemBuild(project.root, ref, project.id) })
|
|
127
|
+
}
|
|
128
|
+
// The Blueprint's definitions as the files state them, so they show before anything is applied.
|
|
129
|
+
if (url.pathname === '/__local/blueprint') {
|
|
130
|
+
return json({ objectTypes: projectBlueprintOutline(project.root) })
|
|
131
|
+
}
|
|
116
132
|
// The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
|
|
117
133
|
if (url.pathname === '/__local/test-cases') {
|
|
118
134
|
try {
|
|
@@ -265,6 +281,11 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
265
281
|
}
|
|
266
282
|
|
|
267
283
|
if (url.pathname === '/__local/health') return json({ ok: true })
|
|
284
|
+
// A built-in connection's stand-in (`local-stand-ins.ts`), which the runtime's service calls as an MCP
|
|
285
|
+
// server: it holds and reaches nothing, and answers every tool with "not connected", so it takes no session.
|
|
286
|
+
// The Documentation plugin's serves the release's customer documentation, which is public to every reader.
|
|
287
|
+
const standIn = /^\/__stand-in\/([A-Za-z0-9._-]+)\/mcp$/.exec(url.pathname)
|
|
288
|
+
if (standIn) return standInMcp(project.root, standIn[1]!, req, docsCopyPath(project))
|
|
268
289
|
|
|
269
290
|
const cookie = cookieToken(req)
|
|
270
291
|
if (!cookie || !sameToken(cookie, opts.sessionToken)) {
|
|
@@ -284,7 +305,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
284
305
|
const edit = (await req.json().catch(() => null)) as AgentFileEdit | null
|
|
285
306
|
if (!edit || typeof edit !== 'object') return json({ error: 'the edit must be a JSON object' }, 400)
|
|
286
307
|
try {
|
|
287
|
-
return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit))
|
|
308
|
+
return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit, opts.serviceUrl))
|
|
288
309
|
} catch (err) {
|
|
289
310
|
if (err instanceof AgentFileError) return json({ error: err.message }, err.status)
|
|
290
311
|
return json({ error: (err as Error).message }, 500)
|