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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +30 -10
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/client.d.ts +77 -2
- package/dist/client.js +233 -31
- package/dist/config.d.ts +14 -0
- package/dist/config.js +19 -0
- package/dist/contract/bundleSnapshot.js +8 -1
- package/dist/contract/healthContract.d.ts +1 -1
- package/dist/contract/healthContract.js +69 -6
- package/dist/contract/types.d.ts +37 -1
- package/dist/index.bundle.js +3786 -16475
- package/dist/inspect.d.ts +88 -0
- package/dist/inspect.js +384 -0
- package/dist/redaction.d.ts +18 -3
- package/dist/redaction.js +27 -3
- package/dist/remote/consent.d.ts +22 -20
- package/dist/remote/consent.js +105 -82
- package/dist/remote/consentMessages.d.ts +62 -0
- package/dist/remote/consentMessages.js +196 -0
- package/dist/remote/handoff.d.ts +10 -0
- package/dist/remote/handoff.js +44 -0
- package/dist/remote/httpServer.js +28 -5
- package/dist/remote/oauth.d.ts +39 -6
- package/dist/remote/oauth.js +280 -35
- package/dist/scaffold.d.ts +47 -0
- package/dist/scaffold.js +108 -0
- package/dist/server.js +278 -24
- package/dist/soak.js +21 -1
- package/dist/templates.d.ts +62 -0
- package/dist/templates.js +255 -0
- package/dist/verify.js +18 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.js +14 -0
- package/mcpb/manifest.json +11 -5
- package/package.json +1 -1
- package/skills/app-configurator/SKILL.md +79 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-configurator",
|
|
3
|
-
"version": "0.1
|
|
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
|
|
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 |
|
|
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 (
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
191
|
-
client registry
|
|
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` |
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
183
|
+
return this.updateEntity('action_plan', id, body);
|
|
110
184
|
}
|
|
111
185
|
updateControl(id, body) {
|
|
112
|
-
return this.
|
|
186
|
+
return this.updateEntity('control', id, body);
|
|
113
187
|
}
|
|
114
188
|
createControl(body) {
|
|
115
|
-
return this.
|
|
189
|
+
return this.createEntity('control', body);
|
|
116
190
|
}
|
|
117
191
|
updateForm(id, body) {
|
|
118
|
-
return this.
|
|
192
|
+
return this.updateEntity('form', id, body);
|
|
119
193
|
}
|
|
120
194
|
updateKnowledge(id, body) {
|
|
121
|
-
return this.
|
|
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
|
-
|
|
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
|
-
*
|
|
168
|
-
*
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
181
|
-
controls
|
|
182
|
-
forms
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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],
|