appilot-mcp 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +102 -24
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +171 -0
  8. package/dist/client.d.ts +122 -3
  9. package/dist/client.js +306 -31
  10. package/dist/config.d.ts +14 -0
  11. package/dist/config.js +19 -0
  12. package/dist/contract/bundleSnapshot.js +8 -1
  13. package/dist/contract/healthContract.d.ts +1 -1
  14. package/dist/contract/healthContract.js +100 -10
  15. package/dist/contract/types.d.ts +37 -1
  16. package/dist/index.bundle.js +4487 -16520
  17. package/dist/index.js +7 -0
  18. package/dist/inspect.d.ts +88 -0
  19. package/dist/inspect.js +384 -0
  20. package/dist/manifest.d.ts +14 -2
  21. package/dist/manifest.js +31 -9
  22. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  23. package/dist/public-marketplace/README.md +23 -0
  24. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  25. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  26. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  27. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  28. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  29. package/dist/redaction.d.ts +18 -3
  30. package/dist/redaction.js +27 -3
  31. package/dist/remote/consent.d.ts +30 -20
  32. package/dist/remote/consent.js +114 -82
  33. package/dist/remote/consentMessages.d.ts +65 -0
  34. package/dist/remote/consentMessages.js +199 -0
  35. package/dist/remote/handoff.d.ts +10 -0
  36. package/dist/remote/handoff.js +44 -0
  37. package/dist/remote/httpServer.js +28 -5
  38. package/dist/remote/oauth.d.ts +39 -6
  39. package/dist/remote/oauth.js +281 -36
  40. package/dist/scaffold.d.ts +110 -1
  41. package/dist/scaffold.js +474 -39
  42. package/dist/server.js +425 -38
  43. package/dist/soak.js +21 -1
  44. package/dist/templates.d.ts +62 -0
  45. package/dist/templates.js +255 -0
  46. package/dist/verify.js +18 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/examples/app.appilot.json +212 -0
  50. package/mcpb/manifest.json +117 -15
  51. package/package.json +5 -3
  52. package/skills/app-configurator/SKILL.md +136 -25
package/dist/client.js CHANGED
@@ -9,6 +9,11 @@
9
9
  * defensively and never throw on a missing field. Field mapping is verified
10
10
  * against a live instance in the plan's Phase F.
11
11
  */
12
+ /**
13
+ * Appilot's trilingual baseline, used only when the instance cannot say which
14
+ * languages an app actually claims to support.
15
+ */
16
+ const DEFAULT_EXPECTED_LOCALES = ['de', 'en', 'es'];
12
17
  export class AppilotApiError extends Error {
13
18
  status;
14
19
  body;
@@ -19,8 +24,17 @@ export class AppilotApiError extends Error {
19
24
  this.name = 'AppilotApiError';
20
25
  }
21
26
  }
22
- /** Merge a base value + an `_i18n` override map into a single LocalizedText. */
23
- function fromBaseAndI18n(base, i18n) {
27
+ /**
28
+ * Merge a base value + an `_i18n` override map into a single LocalizedText.
29
+ *
30
+ * The base column holds the text in the entity's SOURCE locale and `*_i18n`
31
+ * holds every other locale, so the source locale has to be indexed by name.
32
+ * Filing it under a synthetic 'base' key instead made the i18n lint report the
33
+ * source locale as missing on every entity that had any translation at all:
34
+ * a plan authored in `en` with `{ en: … }` and nothing else was told to
35
+ * "provide the name in en".
36
+ */
37
+ function fromBaseAndI18n(base, i18n, sourceLocale) {
24
38
  const out = {};
25
39
  if (i18n && typeof i18n === 'object') {
26
40
  for (const [k, v] of Object.entries(i18n)) {
@@ -29,11 +43,17 @@ function fromBaseAndI18n(base, i18n) {
29
43
  }
30
44
  }
31
45
  if (typeof base === 'string' && base.length) {
32
- // A base string has no locale of its own; expose it under a synthetic
33
- // 'base' key AND leave declared locales as-is. The contract checks named
34
- // locales, so we only surface base when nothing else exists.
35
- if (Object.keys(out).length === 0)
46
+ const src = typeof sourceLocale === 'string' && sourceLocale ? sourceLocale : null;
47
+ // Never let the base overwrite an explicit per-locale value.
48
+ if (src) {
49
+ if (out[src] === undefined)
50
+ out[src] = base;
51
+ }
52
+ else if (Object.keys(out).length === 0) {
53
+ // An instance that does not record a source locale: the text is real
54
+ // but unattributable, so surface it without claiming a locale.
36
55
  out.base = base;
56
+ }
37
57
  }
38
58
  return out;
39
59
  }
@@ -55,8 +75,27 @@ function localized(entity, field) {
55
75
  const rows = entity.translations;
56
76
  if (Array.isArray(rows) && rows.length)
57
77
  return fromTranslationRows(rows, field);
58
- return fromBaseAndI18n(entity[field], entity[`${field}_i18n`]);
78
+ return fromBaseAndI18n(entity[field], entity[`${field}_i18n`], entity.source_locale);
59
79
  }
80
+ /**
81
+ * The eight configurable entities, and where each one is written.
82
+ *
83
+ * Session templates and zones are here for the same reason as the rest: an app
84
+ * whose activities are conversations, or whose pages need landmark regions, was
85
+ * not authorable from a conversation at all, because the only writer that
86
+ * reached them was a whole-bundle import.
87
+ */
88
+ export const ENTITY_PATHS = {
89
+ view: '/views',
90
+ control: '/domain/controls',
91
+ form: '/domain/forms',
92
+ tool: '/domain/tools',
93
+ zone: '/domain/zones',
94
+ action_plan: '/domain/action-plans',
95
+ knowledge: '/knowledge/content',
96
+ session_template: '/domain/session-templates',
97
+ };
98
+ export const CONFIG_ENTITY_KINDS = Object.keys(ENTITY_PATHS);
60
99
  export class AppilotClient {
61
100
  conn;
62
101
  constructor(conn) {
@@ -77,8 +116,8 @@ export class AppilotClient {
77
116
  const text = await res.text();
78
117
  const body = text ? safeJson(text) : undefined;
79
118
  if (!res.ok) {
80
- const message = body?.error ?? `${res.status} ${res.statusText}`;
81
- throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${message}`, res.status, body);
119
+ const credential = res.status === 401 || res.status === 403 ? ` ${credentialAdvice(!!this.conn.token)}` : '';
120
+ throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}${credential}`, res.status, body);
82
121
  }
83
122
  return body;
84
123
  }
@@ -104,21 +143,66 @@ export class AppilotClient {
104
143
  listKnowledge(appId) {
105
144
  return this.request(`/knowledge/content?app_id=${appId}`);
106
145
  }
146
+ listTools(appId) {
147
+ return this.request(`/domain/tools?app_id=${appId}`);
148
+ }
149
+ listZones(appId) {
150
+ return this.request(`/domain/zones?app_id=${appId}`);
151
+ }
152
+ /**
153
+ * The app's domains: which hostnames it serves, the languages each declares,
154
+ * and whether the hostname is verified. On the config surface, so a read-only
155
+ * credential can resolve the locales the configuration is expected to cover.
156
+ */
157
+ listAppDomains(appId) {
158
+ return this.request(`/config/domains?appId=${appId}`);
159
+ }
107
160
  // -- writes -------------------------------------------------------------
161
+ //
162
+ // One create, one update and one delete per configurable entity, so a
163
+ // conversation can author configuration without round-tripping the whole
164
+ // app through a ConfigBundle. The routes below were already reachable by a
165
+ // service token holding `config:write`; what was missing was this surface.
166
+ //
167
+ // The path per entity is the only thing that varies, so the table drives it.
168
+ // A hand-written method per entity is twenty-four near-identical bodies and a
169
+ // place for a typo to hide.
170
+ entityPath(kind, id) {
171
+ const base = ENTITY_PATHS[kind];
172
+ return id == null ? base : `${base}/${encodeURIComponent(id)}`;
173
+ }
174
+ createEntity(kind, body) {
175
+ return this.request(this.entityPath(kind), { method: 'POST', body: JSON.stringify(body) });
176
+ }
177
+ updateEntity(kind, id, body) {
178
+ return this.request(this.entityPath(kind, id), { method: 'PUT', body: JSON.stringify(body) });
179
+ }
180
+ deleteEntity(kind, id) {
181
+ return this.request(this.entityPath(kind, id), { method: 'DELETE' });
182
+ }
108
183
  updateActionPlan(id, body) {
109
- return this.request(`/domain/action-plans/${id}`, { method: 'PUT', body: JSON.stringify(body) });
184
+ return this.updateEntity('action_plan', id, body);
110
185
  }
111
186
  updateControl(id, body) {
112
- return this.request(`/domain/controls/${id}`, { method: 'PUT', body: JSON.stringify(body) });
187
+ return this.updateEntity('control', id, body);
113
188
  }
114
189
  createControl(body) {
115
- return this.request(`/domain/controls`, { method: 'POST', body: JSON.stringify(body) });
190
+ return this.createEntity('control', body);
116
191
  }
117
192
  updateForm(id, body) {
118
- return this.request(`/domain/forms/${id}`, { method: 'PUT', body: JSON.stringify(body) });
193
+ return this.updateEntity('form', id, body);
119
194
  }
120
195
  updateKnowledge(id, body) {
121
- return this.request(`/knowledge/content/${id}`, { method: 'PUT', body: JSON.stringify(body) });
196
+ return this.updateEntity('knowledge', id, body);
197
+ }
198
+ // -- developer feedback -------------------------------------------------
199
+ // The one surface here that sends data OUT of the tenant. See server.ts for
200
+ // the consent rule and redaction.ts for what may not travel.
201
+ createDeveloperReport(body) {
202
+ return this.request('/developer-reports', { method: 'POST', body: JSON.stringify(body) });
203
+ }
204
+ listDeveloperReports(query) {
205
+ return this.request(`/developer-reports${query ? `?${query}` : ''}`);
122
206
  }
123
207
  /** Server-side non-persisting plan validation (echoes the runtime trust boundary). */
124
208
  validatePlan(appId, sections, formValues) {
@@ -134,6 +218,16 @@ export class AppilotClient {
134
218
  listProvisioned() {
135
219
  return this.request('/provision/apps');
136
220
  }
221
+ /**
222
+ * The org's widget keys, prefixes only.
223
+ *
224
+ * A caller that cannot see the keys it already minted asks for another every
225
+ * run, and each one is a live credential.
226
+ */
227
+ async listWidgetKeys() {
228
+ const body = await this.request('/provision/widget-keys');
229
+ return Array.isArray(body?.widgetKeys) ? body.widgetKeys : [];
230
+ }
137
231
  provisionApp(body) {
138
232
  return this.request('/provision/app', { method: 'POST', body: JSON.stringify(body) });
139
233
  }
@@ -141,9 +235,29 @@ export class AppilotClient {
141
235
  * Domain check: does this hostname resolve to a tenant? Public endpoint, so
142
236
  * it answers even for a read-only caller, which is what makes it usable as
143
237
  * the first probe of `verify_integration`.
238
+ *
239
+ * The endpoint takes a URL and derives the hostname itself; passing the bare
240
+ * hostname made the probe answer "URL is required" and report a warning about
241
+ * the tenant on every single run.
144
242
  */
145
243
  checkDomain(domain) {
146
- return this.request(`/domain/check?domain=${encodeURIComponent(domain)}`);
244
+ const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
245
+ return this.request(`/domain/check?url=${encodeURIComponent(url)}`);
246
+ }
247
+ /**
248
+ * The TXT record a domain needs, and where its verification stands.
249
+ *
250
+ * These two live on the apps router rather than under `/provision`, and that
251
+ * router authenticates an organization SESSION. A service token is refused
252
+ * there today, which is why `verify_domain` falls back to the provisioning
253
+ * dry run for the status and says plainly what it could not do.
254
+ */
255
+ domainVerification(domainId) {
256
+ return this.request(`/apps/domains/${domainId}/verification`);
257
+ }
258
+ /** Ask Appilot to look for the TXT record now. */
259
+ triggerDomainVerification(domainId) {
260
+ return this.request(`/apps/domains/${domainId}/verify`, { method: 'POST' });
147
261
  }
148
262
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
149
263
  whoami() {
@@ -164,27 +278,128 @@ export class AppilotClient {
164
278
  }
165
279
  /**
166
280
  * Read the app's content-model config and normalize it into a ConfigSnapshot.
167
- * `expectedLocales` defaults to de/en/es (Appilot's trilingual baseline) but
168
- * can be overridden per call once the domain's configured_languages are known.
281
+ *
282
+ * Two properties matter here and were both missing before.
283
+ *
284
+ * A failed read is a GAP, never an empty list. Swallowing the error made a
285
+ * refused knowledge read indistinguishable from an app with no knowledge, and
286
+ * every knowledge lint then passed on a configuration nobody had looked at.
287
+ *
288
+ * `expectedLocales` comes from the app's own domains when the caller does not
289
+ * say. The trilingual default is Appilot's baseline, not every customer's: a
290
+ * single-language app used to collect two medium i18n findings per entity for
291
+ * languages it had never claimed to support.
169
292
  */
170
- async buildSnapshot(appId, expectedLocales = ['de', 'en', 'es']) {
171
- const [plans, controls, forms, views, knowledge] = await Promise.all([
172
- this.listActionPlans(appId).catch(() => []),
173
- this.listControls(appId).catch(() => []),
174
- this.listForms(appId).catch(() => []),
175
- this.listViews(appId).catch(() => []),
176
- this.listKnowledge(appId).catch(() => []),
293
+ async buildSnapshot(appId, expectedLocales) {
294
+ const gaps = [];
295
+ const read = async (entity, load, map) => {
296
+ try {
297
+ const rows = await load();
298
+ return Array.isArray(rows) ? rows.map(map) : [];
299
+ }
300
+ catch (err) {
301
+ gaps.push({ entity, reason: err instanceof Error ? err.message : String(err) });
302
+ return [];
303
+ }
304
+ };
305
+ const [plans, controls, forms, views, tools, zones, knowledge, locales] = await Promise.all([
306
+ read('actionPlans', () => this.listActionPlans(appId), mapActionPlan),
307
+ read('controls', () => this.listControls(appId), mapControl),
308
+ read('forms', () => this.listForms(appId), mapForm),
309
+ read('views', () => this.listViews(appId), mapView),
310
+ read('tools', () => this.listTools(appId), mapTool),
311
+ read('zones', () => this.listZones(appId), mapZone),
312
+ read('knowledge', () => this.listKnowledge(appId), mapKnowledge),
313
+ expectedLocales?.length ? Promise.resolve(expectedLocales) : this.resolveLocales(appId),
177
314
  ]);
315
+ // An empty knowledge list is the one read that can mean two things.
316
+ // `/knowledge/content` is an optional-auth route, so a missing or rejected
317
+ // bearer answers `200 []` instead of refusing, and from here that is
318
+ // indistinguishable from an app with no knowledge. Every knowledge lint
319
+ // then passes over a configuration nobody was allowed to look at, which is
320
+ // exactly the failure the `gaps` array exists to prevent.
321
+ if (knowledge.length === 0 && !gaps.some(g => g.entity === 'knowledge')) {
322
+ const refusal = await this.credentialRefusal();
323
+ if (refusal)
324
+ gaps.push({ entity: 'knowledge', reason: refusal });
325
+ }
178
326
  return {
179
- expectedLocales,
180
- views: views.map(mapView),
181
- controls: controls.map(mapControl),
182
- forms: forms.map(mapForm),
183
- zones: [], // zones read per-domain; wired in Phase F once domain ids are resolved.
184
- actionPlans: plans.map(mapActionPlan),
185
- knowledge: knowledge.map(mapKnowledge),
327
+ expectedLocales: locales,
328
+ views,
329
+ controls,
330
+ forms,
331
+ tools,
332
+ zones,
333
+ actionPlans: plans,
334
+ knowledge,
335
+ ...(gaps.length ? { gaps } : {}),
186
336
  };
187
337
  }
338
+ /**
339
+ * Why an empty read should not be believed, or null when the credential is
340
+ * fine and the app genuinely has nothing.
341
+ *
342
+ * Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
343
+ * says nothing about the token, and reporting a gap on that would be a
344
+ * confident false alarm on every on-premise deployment behind cloud.
345
+ */
346
+ async credentialRefusal() {
347
+ if (!this.conn.token) {
348
+ return `No service token is set, so the knowledge read was anonymous and answered an empty list rather than refusing. ${credentialAdvice(false)}`;
349
+ }
350
+ try {
351
+ await this.whoami();
352
+ return null;
353
+ }
354
+ catch (err) {
355
+ if (err instanceof AppilotApiError && (err.status === 401 || err.status === 403)) {
356
+ return `The knowledge read returned nothing and the credential failed its own self-check, so the empty result is not evidence of an empty knowledge base. ${credentialAdvice(true)}`;
357
+ }
358
+ return null;
359
+ }
360
+ }
361
+ /**
362
+ * Which locales this app's configuration is expected to cover: the union of
363
+ * its domains' configured languages. Falls back to the trilingual baseline
364
+ * when the instance cannot answer, which is the behaviour every caller had
365
+ * before and is still better than checking nothing.
366
+ */
367
+ async resolveLocales(appId) {
368
+ try {
369
+ const rows = await this.listAppDomains(appId);
370
+ const out = new Set();
371
+ for (const d of rows) {
372
+ const configured = d.configured_languages;
373
+ if (Array.isArray(configured)) {
374
+ for (const l of configured)
375
+ if (typeof l === 'string' && l)
376
+ out.add(l);
377
+ }
378
+ const fallback = d.default_language;
379
+ if (typeof fallback === 'string' && fallback)
380
+ out.add(fallback);
381
+ }
382
+ if (out.size)
383
+ return [...out].sort();
384
+ }
385
+ catch {
386
+ // Older instance, or a credential that cannot read domains.
387
+ }
388
+ return DEFAULT_EXPECTED_LOCALES;
389
+ }
390
+ }
391
+ /**
392
+ * What to do about a 401 or a 403, in the same voice as the offline errors.
393
+ *
394
+ * "Access denied. Token required." is the first thing a new developer meets and
395
+ * it names nothing: not the variable to set, not the screen the token comes
396
+ * from, not the preset to choose. The `APPILOT_BASE_URL` message next door does
397
+ * name all three, and it is the standard this one had to reach.
398
+ */
399
+ function credentialAdvice(hasToken) {
400
+ return hasToken
401
+ ? 'The credential was rejected. Check it in the Backoffice under Service tokens: it may be revoked, expired, or scoped to another app. A token narrowed to one app is refused on every other app in the organization.'
402
+ : 'No credential is set. Put a service token in APPILOT_PAT in the MCP client environment and restart the client. Mint one in the Backoffice under Service tokens: "Inspect only" to read and audit, "Edit configuration" to write, "Set up integrations" to create apps, domains and widget keys.';
188
403
  }
189
404
  function safeJson(text) {
190
405
  try {
@@ -194,11 +409,54 @@ function safeJson(text) {
194
409
  return text;
195
410
  }
196
411
  }
412
+ /**
413
+ * Render a backend error the way a model can act on it.
414
+ *
415
+ * The backend answers with `{ error, code, details, requestId }` (see
416
+ * docs/setup/logging-conventions.md). Reporting only `error` throws away the
417
+ * half that says what to fix: "Bundle failed structural validation" names no
418
+ * field, while `details.errors` names every path. Keep the rendering compact,
419
+ * because this lands in a conversation, and keep `requestId` so a support
420
+ * request can be matched to a server log line.
421
+ */
422
+ function describeError(body, res) {
423
+ const b = (body ?? {});
424
+ if (typeof body !== 'object' || body === null) {
425
+ return typeof body === 'string' && body.trim() ? body.trim() : `${res.status} ${res.statusText}`;
426
+ }
427
+ const parts = [b.error ?? `${res.status} ${res.statusText}`];
428
+ if (b.code)
429
+ parts.push(`[${b.code}]`);
430
+ // The per-field issues. The backend produces four shapes and every one of
431
+ // them is the difference between "Form validation failed" and a refusal the
432
+ // caller can act on: envelope errors and Zod issues under `details`, the
433
+ // forms router's top-level `violations`, and the delete routes' `dependents`
434
+ // on a 409, which names exactly what still points at the entity.
435
+ const issues = (b.details?.errors ?? b.details?.issues ?? b.violations);
436
+ if (Array.isArray(issues) && issues.length) {
437
+ const shown = issues.slice(0, 20).map(i => typeof i === 'string' ? ` ${i}` : ` ${i.path || '(root)'}: ${i.message ?? ''}`.trimEnd());
438
+ if (issues.length > 20)
439
+ shown.push(` and ${issues.length - 20} more`);
440
+ parts.push('\n' + shown.join('\n'));
441
+ }
442
+ else if (Array.isArray(b.dependents) && b.dependents.length) {
443
+ parts.push('\n still referenced by: ' + JSON.stringify(b.dependents));
444
+ }
445
+ else if (b.details && Object.keys(b.details).length) {
446
+ parts.push(`\n ${JSON.stringify(b.details)}`);
447
+ }
448
+ if (b.requestId)
449
+ parts.push(`\n requestId ${b.requestId}`);
450
+ return parts.join(' ').replace(/ \n/g, '\n');
451
+ }
197
452
  function mapView(v) {
198
453
  return { slug: v.slug, path: String(v.path ?? v.view_path ?? ''), name: localized(v, 'name') };
199
454
  }
200
455
  function mapControl(c) {
201
456
  return {
457
+ // The row id is what `update_entity` takes. Without it here the agent had
458
+ // no way to name the control it wanted to patch.
459
+ id: c.id != null ? String(c.id) : undefined,
202
460
  semantic_id: String(c.semantic_id ?? c.name ?? ''),
203
461
  locator_type: String(c.locator_type ?? c.locator_strategy ?? ''),
204
462
  locator: String(c.locator ?? c.locator_value ?? ''),
@@ -218,6 +476,7 @@ function mapForm(f) {
218
476
  function mapActionPlan(p) {
219
477
  const sections = p.sections ?? [];
220
478
  return {
479
+ id: p.id != null ? String(p.id) : undefined,
221
480
  semantic_id: String(p.semantic_id ?? ''),
222
481
  sections,
223
482
  form_values: p.form_values ?? {},
@@ -227,6 +486,22 @@ function mapActionPlan(p) {
227
486
  is_active: p.is_active !== false,
228
487
  };
229
488
  }
489
+ function mapTool(t) {
490
+ const runtime = (t.runtime_spec ?? t.runtimeSpec);
491
+ return {
492
+ id: t.id != null ? String(t.id) : undefined,
493
+ tool_name: String(t.tool_name ?? t.name ?? ''),
494
+ kind: String(runtime?.kind ?? t.kind ?? ''),
495
+ view_path: t.view_path ?? null,
496
+ is_active: t.is_active !== false,
497
+ };
498
+ }
499
+ function mapZone(z) {
500
+ return {
501
+ id: z.id != null ? String(z.id) : undefined,
502
+ semantic_id: String(z.semantic_id ?? ''),
503
+ };
504
+ }
230
505
  function mapKnowledge(k) {
231
506
  // A knowledge article read may return either a single localized row or a group
232
507
  // of language rows. Normalize both into a bodies[] list.
package/dist/config.d.ts CHANGED
@@ -26,6 +26,17 @@ export interface AppilotConnection {
26
26
  * Defaults to stdio, which is the operator's own machine.
27
27
  */
28
28
  transport?: 'stdio' | 'http';
29
+ /**
30
+ * Scopes the CALLER was granted, when a grant sits in front of the credential.
31
+ *
32
+ * Only the remote transport has one: the person approved a specific set on the
33
+ * consent screen, and the service token sealed behind it may carry more. The
34
+ * backend enforces the token's scopes, not the grant's, so without this the
35
+ * consent screen was describing a limit nothing applied. Undefined on stdio,
36
+ * where the operator's own credential is the only authority and there is no
37
+ * grant to narrow it.
38
+ */
39
+ grantedScopes?: string[];
29
40
  }
30
41
  export declare function loadConnection(env?: NodeJS.ProcessEnv): AppilotConnection;
31
42
  /** Which transport the process serves. Local stdio unless asked otherwise. */
@@ -41,6 +52,9 @@ export declare function resolveTransport(argv?: string[], env?: NodeJS.ProcessEn
41
52
  * needs three things and no database.
42
53
  */
43
54
  export interface RemoteConfig {
55
+ handoffSecret?: string;
56
+ /** Trusted Backoffice origin for the connection help and brand assets. */
57
+ backofficeUrl?: string;
44
58
  /** Port to listen on. Cloud Run supplies PORT. */
45
59
  port: number;
46
60
  /** Public origin the clients reach, e.g. https://mcp.appilot.space. */
package/dist/config.js CHANGED
@@ -67,8 +67,27 @@ export function loadRemoteConfig(env = process.env) {
67
67
  catch {
68
68
  throw new RemoteConfigError(`APPILOT_MCP_DOCS_URL is not a valid URL: ${rawDocs}`);
69
69
  }
70
+ const rawBackoffice = env.APPILOT_MCP_BACKOFFICE_URL?.trim() ||
71
+ (trimTrailingSlash(baseUrl) === 'https://api.appilot.space' ? 'https://backoffice.appilot.space' : undefined);
72
+ let backofficeUrl;
73
+ if (rawBackoffice) {
74
+ try {
75
+ const parsed = new URL(rawBackoffice);
76
+ if ((parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(parsed.hostname))) || parsed.username || parsed.password)
77
+ throw new Error();
78
+ backofficeUrl = parsed.origin;
79
+ }
80
+ catch {
81
+ throw new RemoteConfigError('APPILOT_MCP_BACKOFFICE_URL must be an HTTPS origin (localhost excepted).');
82
+ }
83
+ }
84
+ const handoffSecret = env.APPILOT_MCP_HANDOFF_SECRET?.trim();
85
+ if (handoffSecret && (handoffSecret.length < 32 || !backofficeUrl))
86
+ throw new RemoteConfigError('Session approval requires a 32-character handoff secret and a Backoffice origin.');
70
87
  return {
88
+ handoffSecret,
71
89
  port,
90
+ backofficeUrl,
72
91
  publicUrl,
73
92
  secret,
74
93
  allowedHosts: [publicUrl.host, ...extraHosts],
@@ -52,6 +52,11 @@ export function snapshotFromBundle(entities, expectedLocales) {
52
52
  };
53
53
  });
54
54
  const zones = entities.zones.map(z => ({ semantic_id: z.semantic_id }));
55
+ const tools = entities.tools.map(t => ({
56
+ tool_name: t.tool_name,
57
+ kind: String(t.runtime_spec?.kind ?? ''),
58
+ view_path: t.view?.path ?? null,
59
+ }));
55
60
  const knowledge = entities.knowledge_content.map(k => ({
56
61
  id: k.group,
57
62
  scope: k.scope,
@@ -61,5 +66,7 @@ export function snapshotFromBundle(entities, expectedLocales) {
61
66
  title: b.title,
62
67
  })),
63
68
  }));
64
- return { expectedLocales, views, controls, forms, zones, actionPlans, knowledge };
69
+ // A bundle is complete by construction (the envelope validator refuses a
70
+ // partial one), so there is never a gap on this path.
71
+ return { expectedLocales, views, controls, forms, tools, zones, actionPlans, knowledge };
65
72
  }
@@ -16,4 +16,4 @@ import type { ConfigSnapshot, HealthReport } from './types.js';
16
16
  * Run the full config health contract over a snapshot. Findings are returned
17
17
  * most-severe first, so the report reads like the manual audit.
18
18
  */
19
- export declare function runHealthContract(snap: ConfigSnapshot): HealthReport;
19
+ export declare function runHealthContract(input: ConfigSnapshot): HealthReport;