appilot-mcp 0.1.0 → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-configurator",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Set up, configure, audit, repair, back up, restore, and verify an Appilot app: provision the app, its domains and widget key, scaffold the host integration, and keep the content model correct. Uses the Appilot MCP server and the app-configurator skill.",
5
5
  "author": {
6
6
  "name": "Appilot",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-configurator",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Set up, configure, audit, repair, back up, restore, and verify an Appilot app: provision the app, its domains and widget key, scaffold the host integration, and keep the content model correct. Uses the Appilot MCP server and the app-configurator skill.",
5
5
  "author": {
6
6
  "name": "Appilot",
package/README.md CHANGED
@@ -29,7 +29,7 @@ not by preference.
29
29
  | Transport | Who launches it | Credential | Clients |
30
30
  |-----------|-----------------|------------|---------|
31
31
  | **stdio** (default) | The client, as a local child process | `APPILOT_PAT` in the process environment | Claude Code, Codex (CLI, IDE, app), Claude Desktop, Cursor, Antigravity |
32
- | **HTTP** (`--http`) | You, as a deployed service | The caller's own service token, collected once through OAuth | ChatGPT, claude.ai, and any remote MCP client |
32
+ | **HTTP** (`--http`) | You, as a deployed service | Account approval or a manually supplied service token through OAuth | ChatGPT, claude.ai, and any remote MCP client |
33
33
 
34
34
  ChatGPT and claude.ai cannot start a process on your machine, and neither offers
35
35
  a field for pasting an API key: they connect to a URL and authenticate with
@@ -41,7 +41,7 @@ OAuth. That is what HTTP mode is for. See
41
41
  | Env | Required | Meaning |
42
42
  |-----|----------|---------|
43
43
  | `APPILOT_BASE_URL` | yes | Backend URL, e.g. `https://api.appilot.space` or `http://localhost:6001` |
44
- | `APPILOT_PAT` | for writes / server echo | A scoped service token (`appilot_pat_…`), minted in the Backoffice (admin → service tokens). `config:read` to read/validate, `config:write` to apply. |
44
+ | `APPILOT_PAT` | for writes / server echo | A scoped service token (`appilot_pat_…`), minted in the Backoffice (Service tokens). `config:read` to read/validate, `config:write` to apply. |
45
45
  | `APPILOT_APP_ID` | optional | Default app id for tools that omit one |
46
46
  | `APPILOT_SOAK_STORAGE_STATE` | optional | Path to a Playwright `storageState` JSON for an authenticated site session (soak) |
47
47
 
@@ -63,9 +63,14 @@ because each caller brings theirs.
63
63
  | `capabilities` | Version + migration level + schema versions of the connected instance (negotiate before configuring) |
64
64
  | `read_config` | Normalized snapshot of an app's content model |
65
65
  | `validate_config` | Severity-ranked findings against the health contract (local + server echo) |
66
- | `update_action_plan` / `update_control` / `update_knowledge` | Guarded writes (server re-validates the trust boundary) |
66
+ | `entity_template` | A valid skeleton per entity kind, carrying the closed enums this instance accepts |
67
+ | `create_entity` / `update_entity` / `delete_entity` | Authoring across all eight kinds (server re-validates the trust boundary) |
68
+ | `validate_action_plan` | Check draft plan sections before writing them |
69
+ | `inspect_page` | Read a page and rank locator candidates by whether they survive the next render |
70
+ | `scaffold_agent_first` | The four artifacts one capability needs to be operable by the agent |
67
71
  | `export_config` / `import_config` | Whole-app ConfigBundle round trip, dry-run first, merge or replace |
68
72
  | `soak_selectors` | Headless-browser check that each control selector resolves on the live page (needs Playwright) |
73
+ | `report_feedback` / `list_feedback` | Report a gap or a defect in Appilot, and see what this organization has already raised |
69
74
 
70
75
  ## Distribution artifacts
71
76
 
@@ -134,7 +139,7 @@ Once one is running (see [Remote deployment](#remote-deployment)):
134
139
 
135
140
  - **ChatGPT**: Settings, Apps, Advanced settings, turn on Developer mode, then
136
141
  add a connector pointing at `https://<your-host>/mcp` with OAuth. The
137
- authorization step asks for a service token, once.
142
+ authorization step opens Appilot account approval, or manual token consent on older deployments.
138
143
  - **claude.ai**: Settings, Connectors, Add custom connector, same URL.
139
144
  - **Codex** can use the remote endpoint too, if you prefer one shared deployment
140
145
  over a local process: add the URL to `~/.codex/config.toml` and run
@@ -187,8 +192,8 @@ environment forwarding when available.
187
192
 
188
193
  ## Remote deployment
189
194
 
190
- HTTP mode is a small stateless web service. It stores nothing: no user table, no
191
- client registry, no token store. The OAuth authorization server seals its own
195
+ HTTP mode delegates identity and approval storage to the backend. It has no
196
+ local user table, client registry or raw token store. The OAuth authorization server seals its own
192
197
  state into the artifacts it issues (see `src/remote/tokens.ts`), so a second
193
198
  instance behaves exactly like the first and a cold start loses nothing.
194
199
 
@@ -200,7 +205,8 @@ What it serves:
200
205
  | `/.well-known/oauth-protected-resource/mcp` | Points a client at the authorization server |
201
206
  | `/.well-known/oauth-authorization-server` | Metadata, PKCE with S256, dynamic registration |
202
207
  | `/authorize`, `/token`, `/register` | The OAuth 2.1 flow |
203
- | `/consent` | The one page a person sees, where they paste a service token |
208
+ | `/consent` | Manual token verification and review |
209
+ | `/connect/callback` | Redeems account approval bound to the initiating browser |
204
210
  | `/health` (also `/healthz` off Cloud Run) | Liveness |
205
211
 
206
212
  Run it:
@@ -233,9 +239,9 @@ has to read before it can authenticate at all. The upload carries the
233
239
 
234
240
  ### How access works
235
241
 
236
- The service never sees an Appilot password and never mints a credential. A
237
- person creates a scoped service token in the Backoffice, pastes it once on the
238
- consent screen, and the service checks it against the instance
242
+ The service never sees an Appilot password. Account approval asks the backend
243
+ to create a scoped credential after the administrator reviews access. In the
244
+ manual flow, a person pastes a previously created token and MCP checks it against the instance
239
245
  (`GET /config/whoami`) before granting anything. The granted scopes are the
240
246
  intersection of what the client asked for and what that token actually carries,
241
247
  so a `config:read` token cannot be talked into write access. The token is then
@@ -271,3 +277,17 @@ Live DOM soak needs Playwright: add it with pnpm and install Chromium.
271
277
 
272
278
  See `docs/architecture/appilot-mcp.md` (server) and
273
279
  `docs/content-model/config-health-contract.md` (the contract it enforces).
280
+
281
+ ### Account-based remote approval
282
+
283
+ Remote deployments can reuse the Backoffice session for approval without asking
284
+ users to create or paste a token. Configure APPILOT_MCP_HANDOFF_SECRET on backend
285
+ and MCP, a trusted APPILOT_MCP_BACKOFFICE_URL on MCP, and the fixed
286
+ APPILOT_MCP_PUBLIC_URL on backend. Deploy migration system/025 and the Backoffice
287
+ approval page first. The cloud activation script is
288
+ `scripts/enable-session-approval.sh`. Keep this secret separate from the OAuth key.
289
+
290
+ Manual service-token consent remains available when session approval is not
291
+ configured, or with `manual=1` on the authorization request. Consent and errors
292
+ support English, Spanish and German. Administrators can filter connections,
293
+ renew expiry and revoke access in Backoffice Service tokens.
Binary file
package/dist/client.d.ts CHANGED
@@ -26,6 +26,13 @@ export interface Capabilities {
26
26
  bundleFormatVersion: string;
27
27
  maxImportBytes: number;
28
28
  };
29
+ /**
30
+ * The closed enums the write paths reject on, sourced from the instance so
31
+ * the published list and the validator are one list. Absent on instances
32
+ * that predate it, which is why every reader treats it as optional: an agent
33
+ * with no vocabulary guesses, and `css` is the guess it makes.
34
+ */
35
+ entityVocabularies?: Record<string, unknown>;
29
36
  }
30
37
  /** Mirrors the backend's ProvisioningService result. Secrets appear once. */
31
38
  export interface ProvisionAppResponse {
@@ -89,6 +96,40 @@ export declare class AppilotApiError extends Error {
89
96
  readonly body?: unknown;
90
97
  constructor(message: string, status: number, body?: unknown);
91
98
  }
99
+ /**
100
+ * The eight configurable entities, and where each one is written.
101
+ *
102
+ * Session templates and zones are here for the same reason as the rest: an app
103
+ * whose activities are conversations, or whose pages need landmark regions, was
104
+ * not authorable from a conversation at all, because the only writer that
105
+ * reached them was a whole-bundle import.
106
+ */
107
+ export declare const ENTITY_PATHS: {
108
+ readonly view: "/views";
109
+ readonly control: "/domain/controls";
110
+ readonly form: "/domain/forms";
111
+ readonly tool: "/domain/tools";
112
+ readonly zone: "/domain/zones";
113
+ readonly action_plan: "/domain/action-plans";
114
+ readonly knowledge: "/knowledge/content";
115
+ readonly session_template: "/domain/session-templates";
116
+ };
117
+ export type ConfigEntityKind = keyof typeof ENTITY_PATHS;
118
+ export declare const CONFIG_ENTITY_KINDS: ConfigEntityKind[];
119
+ /** What `POST /developer-reports` answers. The posture is the point of it. */
120
+ export interface DeveloperReportResponse {
121
+ report: {
122
+ id: string;
123
+ status: string;
124
+ occurrences: number;
125
+ kind: string;
126
+ title: string;
127
+ };
128
+ supportPosture: {
129
+ eligible: boolean;
130
+ message: string;
131
+ };
132
+ }
92
133
  export declare class AppilotClient {
93
134
  private readonly conn;
94
135
  constructor(conn: AppilotConnection);
@@ -103,11 +144,25 @@ export declare class AppilotClient {
103
144
  listForms(appId: number): Promise<Record<string, unknown>[]>;
104
145
  listViews(appId: number): Promise<Record<string, unknown>[]>;
105
146
  listKnowledge(appId: number): Promise<Record<string, unknown>[]>;
147
+ listTools(appId: number): Promise<Record<string, unknown>[]>;
148
+ listZones(appId: number): Promise<Record<string, unknown>[]>;
149
+ /**
150
+ * The app's domains: which hostnames it serves, the languages each declares,
151
+ * and whether the hostname is verified. On the config surface, so a read-only
152
+ * credential can resolve the locales the configuration is expected to cover.
153
+ */
154
+ listAppDomains(appId: number): Promise<Record<string, unknown>[]>;
155
+ private entityPath;
156
+ createEntity(kind: ConfigEntityKind, body: unknown): Promise<unknown>;
157
+ updateEntity(kind: ConfigEntityKind, id: string, body: unknown): Promise<unknown>;
158
+ deleteEntity(kind: ConfigEntityKind, id: string): Promise<unknown>;
106
159
  updateActionPlan(id: string, body: unknown): Promise<unknown>;
107
160
  updateControl(id: string, body: unknown): Promise<unknown>;
108
161
  createControl(body: unknown): Promise<unknown>;
109
162
  updateForm(id: string, body: unknown): Promise<unknown>;
110
163
  updateKnowledge(id: string, body: unknown): Promise<unknown>;
164
+ createDeveloperReport(body: unknown): Promise<DeveloperReportResponse>;
165
+ listDeveloperReports(query: string): Promise<unknown>;
111
166
  /** Server-side non-persisting plan validation (echoes the runtime trust boundary). */
112
167
  validatePlan(appId: number, sections: unknown, formValues: unknown): Promise<{
113
168
  ok: boolean;
@@ -119,6 +174,10 @@ export declare class AppilotClient {
119
174
  * Domain check: does this hostname resolve to a tenant? Public endpoint, so
120
175
  * it answers even for a read-only caller, which is what makes it usable as
121
176
  * the first probe of `verify_integration`.
177
+ *
178
+ * The endpoint takes a URL and derives the hostname itself; passing the bare
179
+ * hostname made the probe answer "URL is required" and report a warning about
180
+ * the tenant on every single run.
122
181
  */
123
182
  checkDomain(domain: string): Promise<Record<string, unknown>>;
124
183
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
@@ -133,8 +192,24 @@ export declare class AppilotClient {
133
192
  }): Promise<import('appilot-shared/config-bundle').ImportDiff>;
134
193
  /**
135
194
  * Read the app's content-model config and normalize it into a ConfigSnapshot.
136
- * `expectedLocales` defaults to de/en/es (Appilot's trilingual baseline) but
137
- * can be overridden per call once the domain's configured_languages are known.
195
+ *
196
+ * Two properties matter here and were both missing before.
197
+ *
198
+ * A failed read is a GAP, never an empty list. Swallowing the error made a
199
+ * refused knowledge read indistinguishable from an app with no knowledge, and
200
+ * every knowledge lint then passed on a configuration nobody had looked at.
201
+ *
202
+ * `expectedLocales` comes from the app's own domains when the caller does not
203
+ * say. The trilingual default is Appilot's baseline, not every customer's: a
204
+ * single-language app used to collect two medium i18n findings per entity for
205
+ * languages it had never claimed to support.
138
206
  */
139
207
  buildSnapshot(appId: number, expectedLocales?: string[]): Promise<ConfigSnapshot>;
208
+ /**
209
+ * Which locales this app's configuration is expected to cover: the union of
210
+ * its domains' configured languages. Falls back to the trilingual baseline
211
+ * when the instance cannot answer, which is the behaviour every caller had
212
+ * before and is still better than checking nothing.
213
+ */
214
+ private resolveLocales;
140
215
  }
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,7 @@ 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
+ throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}`, res.status, body);
82
120
  }
83
121
  return body;
84
122
  }
@@ -104,21 +142,66 @@ export class AppilotClient {
104
142
  listKnowledge(appId) {
105
143
  return this.request(`/knowledge/content?app_id=${appId}`);
106
144
  }
145
+ listTools(appId) {
146
+ return this.request(`/domain/tools?app_id=${appId}`);
147
+ }
148
+ listZones(appId) {
149
+ return this.request(`/domain/zones?app_id=${appId}`);
150
+ }
151
+ /**
152
+ * The app's domains: which hostnames it serves, the languages each declares,
153
+ * and whether the hostname is verified. On the config surface, so a read-only
154
+ * credential can resolve the locales the configuration is expected to cover.
155
+ */
156
+ listAppDomains(appId) {
157
+ return this.request(`/config/domains?appId=${appId}`);
158
+ }
107
159
  // -- writes -------------------------------------------------------------
160
+ //
161
+ // One create, one update and one delete per configurable entity, so a
162
+ // conversation can author configuration without round-tripping the whole
163
+ // app through a ConfigBundle. The routes below were already reachable by a
164
+ // service token holding `config:write`; what was missing was this surface.
165
+ //
166
+ // The path per entity is the only thing that varies, so the table drives it.
167
+ // A hand-written method per entity is twenty-four near-identical bodies and a
168
+ // place for a typo to hide.
169
+ entityPath(kind, id) {
170
+ const base = ENTITY_PATHS[kind];
171
+ return id == null ? base : `${base}/${encodeURIComponent(id)}`;
172
+ }
173
+ createEntity(kind, body) {
174
+ return this.request(this.entityPath(kind), { method: 'POST', body: JSON.stringify(body) });
175
+ }
176
+ updateEntity(kind, id, body) {
177
+ return this.request(this.entityPath(kind, id), { method: 'PUT', body: JSON.stringify(body) });
178
+ }
179
+ deleteEntity(kind, id) {
180
+ return this.request(this.entityPath(kind, id), { method: 'DELETE' });
181
+ }
108
182
  updateActionPlan(id, body) {
109
- return this.request(`/domain/action-plans/${id}`, { method: 'PUT', body: JSON.stringify(body) });
183
+ return this.updateEntity('action_plan', id, body);
110
184
  }
111
185
  updateControl(id, body) {
112
- return this.request(`/domain/controls/${id}`, { method: 'PUT', body: JSON.stringify(body) });
186
+ return this.updateEntity('control', id, body);
113
187
  }
114
188
  createControl(body) {
115
- return this.request(`/domain/controls`, { method: 'POST', body: JSON.stringify(body) });
189
+ return this.createEntity('control', body);
116
190
  }
117
191
  updateForm(id, body) {
118
- return this.request(`/domain/forms/${id}`, { method: 'PUT', body: JSON.stringify(body) });
192
+ return this.updateEntity('form', id, body);
119
193
  }
120
194
  updateKnowledge(id, body) {
121
- return this.request(`/knowledge/content/${id}`, { method: 'PUT', body: JSON.stringify(body) });
195
+ return this.updateEntity('knowledge', id, body);
196
+ }
197
+ // -- developer feedback -------------------------------------------------
198
+ // The one surface here that sends data OUT of the tenant. See server.ts for
199
+ // the consent rule and redaction.ts for what may not travel.
200
+ createDeveloperReport(body) {
201
+ return this.request('/developer-reports', { method: 'POST', body: JSON.stringify(body) });
202
+ }
203
+ listDeveloperReports(query) {
204
+ return this.request(`/developer-reports${query ? `?${query}` : ''}`);
122
205
  }
123
206
  /** Server-side non-persisting plan validation (echoes the runtime trust boundary). */
124
207
  validatePlan(appId, sections, formValues) {
@@ -141,9 +224,14 @@ export class AppilotClient {
141
224
  * Domain check: does this hostname resolve to a tenant? Public endpoint, so
142
225
  * it answers even for a read-only caller, which is what makes it usable as
143
226
  * the first probe of `verify_integration`.
227
+ *
228
+ * The endpoint takes a URL and derives the hostname itself; passing the bare
229
+ * hostname made the probe answer "URL is required" and report a warning about
230
+ * the tenant on every single run.
144
231
  */
145
232
  checkDomain(domain) {
146
- return this.request(`/domain/check?domain=${encodeURIComponent(domain)}`);
233
+ const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
234
+ return this.request(`/domain/check?url=${encodeURIComponent(url)}`);
147
235
  }
148
236
  /** Who this credential is: org, app narrowing, scopes. Never a secret. */
149
237
  whoami() {
@@ -164,27 +252,81 @@ export class AppilotClient {
164
252
  }
165
253
  /**
166
254
  * 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.
255
+ *
256
+ * Two properties matter here and were both missing before.
257
+ *
258
+ * A failed read is a GAP, never an empty list. Swallowing the error made a
259
+ * refused knowledge read indistinguishable from an app with no knowledge, and
260
+ * every knowledge lint then passed on a configuration nobody had looked at.
261
+ *
262
+ * `expectedLocales` comes from the app's own domains when the caller does not
263
+ * say. The trilingual default is Appilot's baseline, not every customer's: a
264
+ * single-language app used to collect two medium i18n findings per entity for
265
+ * languages it had never claimed to support.
169
266
  */
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(() => []),
267
+ async buildSnapshot(appId, expectedLocales) {
268
+ const gaps = [];
269
+ const read = async (entity, load, map) => {
270
+ try {
271
+ const rows = await load();
272
+ return Array.isArray(rows) ? rows.map(map) : [];
273
+ }
274
+ catch (err) {
275
+ gaps.push({ entity, reason: err instanceof Error ? err.message : String(err) });
276
+ return [];
277
+ }
278
+ };
279
+ const [plans, controls, forms, views, tools, zones, knowledge, locales] = await Promise.all([
280
+ read('actionPlans', () => this.listActionPlans(appId), mapActionPlan),
281
+ read('controls', () => this.listControls(appId), mapControl),
282
+ read('forms', () => this.listForms(appId), mapForm),
283
+ read('views', () => this.listViews(appId), mapView),
284
+ read('tools', () => this.listTools(appId), mapTool),
285
+ read('zones', () => this.listZones(appId), mapZone),
286
+ read('knowledge', () => this.listKnowledge(appId), mapKnowledge),
287
+ expectedLocales?.length ? Promise.resolve(expectedLocales) : this.resolveLocales(appId),
177
288
  ]);
178
289
  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),
290
+ expectedLocales: locales,
291
+ views,
292
+ controls,
293
+ forms,
294
+ tools,
295
+ zones,
296
+ actionPlans: plans,
297
+ knowledge,
298
+ ...(gaps.length ? { gaps } : {}),
186
299
  };
187
300
  }
301
+ /**
302
+ * Which locales this app's configuration is expected to cover: the union of
303
+ * its domains' configured languages. Falls back to the trilingual baseline
304
+ * when the instance cannot answer, which is the behaviour every caller had
305
+ * before and is still better than checking nothing.
306
+ */
307
+ async resolveLocales(appId) {
308
+ try {
309
+ const rows = await this.listAppDomains(appId);
310
+ const out = new Set();
311
+ for (const d of rows) {
312
+ const configured = d.configured_languages;
313
+ if (Array.isArray(configured)) {
314
+ for (const l of configured)
315
+ if (typeof l === 'string' && l)
316
+ out.add(l);
317
+ }
318
+ const fallback = d.default_language;
319
+ if (typeof fallback === 'string' && fallback)
320
+ out.add(fallback);
321
+ }
322
+ if (out.size)
323
+ return [...out].sort();
324
+ }
325
+ catch {
326
+ // Older instance, or a credential that cannot read domains.
327
+ }
328
+ return DEFAULT_EXPECTED_LOCALES;
329
+ }
188
330
  }
189
331
  function safeJson(text) {
190
332
  try {
@@ -194,11 +336,54 @@ function safeJson(text) {
194
336
  return text;
195
337
  }
196
338
  }
339
+ /**
340
+ * Render a backend error the way a model can act on it.
341
+ *
342
+ * The backend answers with `{ error, code, details, requestId }` (see
343
+ * docs/setup/logging-conventions.md). Reporting only `error` throws away the
344
+ * half that says what to fix: "Bundle failed structural validation" names no
345
+ * field, while `details.errors` names every path. Keep the rendering compact,
346
+ * because this lands in a conversation, and keep `requestId` so a support
347
+ * request can be matched to a server log line.
348
+ */
349
+ function describeError(body, res) {
350
+ const b = (body ?? {});
351
+ if (typeof body !== 'object' || body === null) {
352
+ return typeof body === 'string' && body.trim() ? body.trim() : `${res.status} ${res.statusText}`;
353
+ }
354
+ const parts = [b.error ?? `${res.status} ${res.statusText}`];
355
+ if (b.code)
356
+ parts.push(`[${b.code}]`);
357
+ // The per-field issues. The backend produces four shapes and every one of
358
+ // them is the difference between "Form validation failed" and a refusal the
359
+ // caller can act on: envelope errors and Zod issues under `details`, the
360
+ // forms router's top-level `violations`, and the delete routes' `dependents`
361
+ // on a 409, which names exactly what still points at the entity.
362
+ const issues = (b.details?.errors ?? b.details?.issues ?? b.violations);
363
+ if (Array.isArray(issues) && issues.length) {
364
+ const shown = issues.slice(0, 20).map(i => typeof i === 'string' ? ` ${i}` : ` ${i.path || '(root)'}: ${i.message ?? ''}`.trimEnd());
365
+ if (issues.length > 20)
366
+ shown.push(` and ${issues.length - 20} more`);
367
+ parts.push('\n' + shown.join('\n'));
368
+ }
369
+ else if (Array.isArray(b.dependents) && b.dependents.length) {
370
+ parts.push('\n still referenced by: ' + JSON.stringify(b.dependents));
371
+ }
372
+ else if (b.details && Object.keys(b.details).length) {
373
+ parts.push(`\n ${JSON.stringify(b.details)}`);
374
+ }
375
+ if (b.requestId)
376
+ parts.push(`\n requestId ${b.requestId}`);
377
+ return parts.join(' ').replace(/ \n/g, '\n');
378
+ }
197
379
  function mapView(v) {
198
380
  return { slug: v.slug, path: String(v.path ?? v.view_path ?? ''), name: localized(v, 'name') };
199
381
  }
200
382
  function mapControl(c) {
201
383
  return {
384
+ // The row id is what `update_entity` takes. Without it here the agent had
385
+ // no way to name the control it wanted to patch.
386
+ id: c.id != null ? String(c.id) : undefined,
202
387
  semantic_id: String(c.semantic_id ?? c.name ?? ''),
203
388
  locator_type: String(c.locator_type ?? c.locator_strategy ?? ''),
204
389
  locator: String(c.locator ?? c.locator_value ?? ''),
@@ -218,6 +403,7 @@ function mapForm(f) {
218
403
  function mapActionPlan(p) {
219
404
  const sections = p.sections ?? [];
220
405
  return {
406
+ id: p.id != null ? String(p.id) : undefined,
221
407
  semantic_id: String(p.semantic_id ?? ''),
222
408
  sections,
223
409
  form_values: p.form_values ?? {},
@@ -227,6 +413,22 @@ function mapActionPlan(p) {
227
413
  is_active: p.is_active !== false,
228
414
  };
229
415
  }
416
+ function mapTool(t) {
417
+ const runtime = (t.runtime_spec ?? t.runtimeSpec);
418
+ return {
419
+ id: t.id != null ? String(t.id) : undefined,
420
+ tool_name: String(t.tool_name ?? t.name ?? ''),
421
+ kind: String(runtime?.kind ?? t.kind ?? ''),
422
+ view_path: t.view_path ?? null,
423
+ is_active: t.is_active !== false,
424
+ };
425
+ }
426
+ function mapZone(z) {
427
+ return {
428
+ id: z.id != null ? String(z.id) : undefined,
429
+ semantic_id: String(z.semantic_id ?? ''),
430
+ };
431
+ }
230
432
  function mapKnowledge(k) {
231
433
  // A knowledge article read may return either a single localized row or a group
232
434
  // 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],