appilot-mcp 0.0.1 → 0.1.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.
- package/.claude-plugin/plugin.json +43 -0
- package/.codex-plugin/plugin.json +37 -0
- package/.mcp.json +19 -0
- package/README.md +268 -6
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.js +252 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +78 -0
- package/dist/contract/bundleSnapshot.d.ts +12 -0
- package/dist/contract/bundleSnapshot.js +65 -0
- package/dist/contract/healthContract.d.ts +19 -0
- package/dist/contract/healthContract.js +297 -0
- package/dist/contract/index.d.ts +3 -0
- package/dist/contract/index.js +3 -0
- package/dist/contract/types.d.ts +86 -0
- package/dist/contract/types.js +9 -0
- package/dist/index.bundle.js +70059 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +50 -0
- package/dist/manifest.d.ts +93 -0
- package/dist/manifest.js +147 -0
- package/dist/redaction.d.ts +30 -0
- package/dist/redaction.js +33 -0
- package/dist/remote/consent.d.ts +29 -0
- package/dist/remote/consent.js +99 -0
- package/dist/remote/httpServer.d.ts +20 -0
- package/dist/remote/httpServer.js +125 -0
- package/dist/remote/oauth.d.ts +74 -0
- package/dist/remote/oauth.js +288 -0
- package/dist/remote/tokens.d.ts +28 -0
- package/dist/remote/tokens.js +50 -0
- package/dist/scaffold.d.ts +37 -0
- package/dist/scaffold.js +203 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +358 -0
- package/dist/soak.d.ts +32 -0
- package/dist/soak.js +51 -0
- package/dist/verify.d.ts +40 -0
- package/dist/verify.js +149 -0
- package/mcpb/manifest.json +67 -0
- package/package.json +70 -16
- package/skills/app-configurator/SKILL.md +198 -0
- package/skills/app-configurator/agents/openai.yaml +13 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client over the Appilot config API. Adds service-token auth, reads
|
|
3
|
+
* the content-model entities, and normalizes them into a `ConfigSnapshot` for
|
|
4
|
+
* the health contract. Also exposes the non-persisting server-side plan
|
|
5
|
+
* validation echo.
|
|
6
|
+
*
|
|
7
|
+
* Translation shapes vary by instance version (base + `*_i18n` map on newer
|
|
8
|
+
* builds, per-locale side-table rows on others), so the normalizers below read
|
|
9
|
+
* defensively and never throw on a missing field. Field mapping is verified
|
|
10
|
+
* against a live instance in the plan's Phase F.
|
|
11
|
+
*/
|
|
12
|
+
export class AppilotApiError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
body;
|
|
15
|
+
constructor(message, status, body) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
this.name = 'AppilotApiError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Merge a base value + an `_i18n` override map into a single LocalizedText. */
|
|
23
|
+
function fromBaseAndI18n(base, i18n) {
|
|
24
|
+
const out = {};
|
|
25
|
+
if (i18n && typeof i18n === 'object') {
|
|
26
|
+
for (const [k, v] of Object.entries(i18n)) {
|
|
27
|
+
if (typeof v === 'string')
|
|
28
|
+
out[k] = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
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)
|
|
36
|
+
out.base = base;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Build a LocalizedText from a `translations` array of `{ locale/language, [field] }`. */
|
|
41
|
+
function fromTranslationRows(rows, field) {
|
|
42
|
+
const out = {};
|
|
43
|
+
if (Array.isArray(rows)) {
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
const locale = (row.locale ?? row.language);
|
|
46
|
+
const value = row[field];
|
|
47
|
+
if (locale && typeof value === 'string')
|
|
48
|
+
out[locale] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Resolve a translatable field across the shapes an instance may return. */
|
|
54
|
+
function localized(entity, field) {
|
|
55
|
+
const rows = entity.translations;
|
|
56
|
+
if (Array.isArray(rows) && rows.length)
|
|
57
|
+
return fromTranslationRows(rows, field);
|
|
58
|
+
return fromBaseAndI18n(entity[field], entity[`${field}_i18n`]);
|
|
59
|
+
}
|
|
60
|
+
export class AppilotClient {
|
|
61
|
+
conn;
|
|
62
|
+
constructor(conn) {
|
|
63
|
+
this.conn = conn;
|
|
64
|
+
}
|
|
65
|
+
async request(path, init = {}) {
|
|
66
|
+
if (!this.conn.baseUrl) {
|
|
67
|
+
throw new Error('APPILOT_BASE_URL is not configured. Set it in the MCP client environment ' +
|
|
68
|
+
'(for example, https://api.appilot.space or http://localhost:6001), then restart the client.');
|
|
69
|
+
}
|
|
70
|
+
const headers = {
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
|
73
|
+
...(this.conn.token ? { Authorization: `Bearer ${this.conn.token}` } : {}),
|
|
74
|
+
...init.headers,
|
|
75
|
+
};
|
|
76
|
+
const res = await fetch(`${this.conn.baseUrl}${path}`, { ...init, headers });
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
const body = text ? safeJson(text) : undefined;
|
|
79
|
+
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);
|
|
82
|
+
}
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
getCapabilities() {
|
|
86
|
+
return this.request('/config/capabilities');
|
|
87
|
+
}
|
|
88
|
+
getHealth() {
|
|
89
|
+
return this.request('/health');
|
|
90
|
+
}
|
|
91
|
+
// -- reads --------------------------------------------------------------
|
|
92
|
+
listActionPlans(appId) {
|
|
93
|
+
return this.request(`/domain/action-plans?app_id=${appId}`);
|
|
94
|
+
}
|
|
95
|
+
listControls(appId) {
|
|
96
|
+
return this.request(`/domain/controls?app_id=${appId}`);
|
|
97
|
+
}
|
|
98
|
+
listForms(appId) {
|
|
99
|
+
return this.request(`/domain/forms?app_id=${appId}&resolveFields=true`);
|
|
100
|
+
}
|
|
101
|
+
listViews(appId) {
|
|
102
|
+
return this.request(`/views?app_id=${appId}`);
|
|
103
|
+
}
|
|
104
|
+
listKnowledge(appId) {
|
|
105
|
+
return this.request(`/knowledge/content?app_id=${appId}`);
|
|
106
|
+
}
|
|
107
|
+
// -- writes -------------------------------------------------------------
|
|
108
|
+
updateActionPlan(id, body) {
|
|
109
|
+
return this.request(`/domain/action-plans/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
110
|
+
}
|
|
111
|
+
updateControl(id, body) {
|
|
112
|
+
return this.request(`/domain/controls/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
113
|
+
}
|
|
114
|
+
createControl(body) {
|
|
115
|
+
return this.request(`/domain/controls`, { method: 'POST', body: JSON.stringify(body) });
|
|
116
|
+
}
|
|
117
|
+
updateForm(id, body) {
|
|
118
|
+
return this.request(`/domain/forms/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
119
|
+
}
|
|
120
|
+
updateKnowledge(id, body) {
|
|
121
|
+
return this.request(`/knowledge/content/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
122
|
+
}
|
|
123
|
+
/** Server-side non-persisting plan validation (echoes the runtime trust boundary). */
|
|
124
|
+
validatePlan(appId, sections, formValues) {
|
|
125
|
+
return this.request('/domain/action-plans/validate', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: JSON.stringify({ app_id: appId, sections, form_values: formValues }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// -- provisioning (plan/external-developer-surface) ---------------------
|
|
131
|
+
// Requires a service token holding `provision:write` (or an org admin JWT).
|
|
132
|
+
// Both calls are idempotent and dry-runnable server-side, so `plan` before
|
|
133
|
+
// `apply` is a property of the endpoint rather than a request in prose.
|
|
134
|
+
listProvisioned() {
|
|
135
|
+
return this.request('/provision/apps');
|
|
136
|
+
}
|
|
137
|
+
provisionApp(body) {
|
|
138
|
+
return this.request('/provision/app', { method: 'POST', body: JSON.stringify(body) });
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Domain check: does this hostname resolve to a tenant? Public endpoint, so
|
|
142
|
+
* it answers even for a read-only caller, which is what makes it usable as
|
|
143
|
+
* the first probe of `verify_integration`.
|
|
144
|
+
*/
|
|
145
|
+
checkDomain(domain) {
|
|
146
|
+
return this.request(`/domain/check?domain=${encodeURIComponent(domain)}`);
|
|
147
|
+
}
|
|
148
|
+
/** Who this credential is: org, app narrowing, scopes. Never a secret. */
|
|
149
|
+
whoami() {
|
|
150
|
+
return this.request('/config/whoami');
|
|
151
|
+
}
|
|
152
|
+
// -- config portability (docs/architecture/config-portability.md) -------
|
|
153
|
+
// exportConfig is the ROUND-TRIP view (the canonical ConfigBundle) as
|
|
154
|
+
// opposed to buildSnapshot, which is the agent's REASONING view; the two
|
|
155
|
+
// are deliberately distinct shapes.
|
|
156
|
+
exportConfig(appId) {
|
|
157
|
+
return this.request(`/config/export?appId=${appId}`);
|
|
158
|
+
}
|
|
159
|
+
importConfig(appId, body) {
|
|
160
|
+
return this.request('/config/import', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
body: JSON.stringify({ appId, ...body }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 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.
|
|
169
|
+
*/
|
|
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(() => []),
|
|
177
|
+
]);
|
|
178
|
+
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),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function safeJson(text) {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(text);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function mapView(v) {
|
|
198
|
+
return { slug: v.slug, path: String(v.path ?? v.view_path ?? ''), name: localized(v, 'name') };
|
|
199
|
+
}
|
|
200
|
+
function mapControl(c) {
|
|
201
|
+
return {
|
|
202
|
+
semantic_id: String(c.semantic_id ?? c.name ?? ''),
|
|
203
|
+
locator_type: String(c.locator_type ?? c.locator_strategy ?? ''),
|
|
204
|
+
locator: String(c.locator ?? c.locator_value ?? ''),
|
|
205
|
+
view_path: c.view_path ?? null,
|
|
206
|
+
scope: c.scope ?? null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function mapForm(f) {
|
|
210
|
+
const fields = f.fields ?? [];
|
|
211
|
+
return {
|
|
212
|
+
semantic_id: String(f.semantic_id ?? ''),
|
|
213
|
+
field_ids: fields.map(fl => String(fl.semantic_id ?? fl.control_id ?? '')).filter(Boolean),
|
|
214
|
+
submit_control_id: f.submit_control_semantic_id ?? f.submit_control_id ?? null,
|
|
215
|
+
entry_control_id: f.entry_control_semantic_id ?? f.entry_control_id ?? null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function mapActionPlan(p) {
|
|
219
|
+
const sections = p.sections ?? [];
|
|
220
|
+
return {
|
|
221
|
+
semantic_id: String(p.semantic_id ?? ''),
|
|
222
|
+
sections,
|
|
223
|
+
form_values: p.form_values ?? {},
|
|
224
|
+
name: localized(p, 'name'),
|
|
225
|
+
description: localized(p, 'description'),
|
|
226
|
+
step_narratives: p.step_narratives_i18n ?? undefined,
|
|
227
|
+
is_active: p.is_active !== false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function mapKnowledge(k) {
|
|
231
|
+
// A knowledge article read may return either a single localized row or a group
|
|
232
|
+
// of language rows. Normalize both into a bodies[] list.
|
|
233
|
+
const bodies = [];
|
|
234
|
+
const rows = (k.translations ?? k.languages ?? k.contents);
|
|
235
|
+
if (Array.isArray(rows) && rows.length) {
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
bodies.push({
|
|
238
|
+
language: String(row.language ?? row.locale ?? ''),
|
|
239
|
+
text: String(row.general_info ?? row.description ?? row.body ?? row.title ?? ''),
|
|
240
|
+
title: row.title,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
bodies.push({
|
|
246
|
+
language: String(k.language ?? ''),
|
|
247
|
+
text: String(k.general_info ?? k.description ?? k.body ?? ''),
|
|
248
|
+
title: k.title,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return { id: k.article_id ?? k.id ?? '', scope: String(k.scope ?? ''), bodies };
|
|
252
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection profile for the Appilot MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint-agnostic by design: the same server binary talks to a cloud tenant or
|
|
5
|
+
* an on-premise / sovereign instance purely by pointing APPILOT_BASE_URL at it.
|
|
6
|
+
* Auth is a scoped service token (appilot_pat_…); no token is needed for the
|
|
7
|
+
* public capabilities/health probes. Nothing here is hard-coded to a host.
|
|
8
|
+
*/
|
|
9
|
+
export interface AppilotConnection {
|
|
10
|
+
/** Base URL of the Appilot backend, e.g. https://api.appilot.space or http://localhost:6001. */
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Scoped service token (appilot_pat_…). Optional for read-only capability probes. */
|
|
13
|
+
token?: string;
|
|
14
|
+
/** Default app id for tools that omit one. */
|
|
15
|
+
defaultAppId?: number;
|
|
16
|
+
/** Optional site session for the soak tool (see soak.ts). */
|
|
17
|
+
soakStorageStatePath?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Which transport this server instance is serving.
|
|
20
|
+
*
|
|
21
|
+
* The tool surface is identical across both, by design. What differs is what
|
|
22
|
+
* a tool may put in its RESULT: the remote transport carries results into a
|
|
23
|
+
* third party's conversation (ChatGPT, claude.ai), where they are stored and
|
|
24
|
+
* summarized. A newly minted widget SECRET must not travel that way, so
|
|
25
|
+
* provisioning withholds it there and points at the Backoffice instead.
|
|
26
|
+
* Defaults to stdio, which is the operator's own machine.
|
|
27
|
+
*/
|
|
28
|
+
transport?: 'stdio' | 'http';
|
|
29
|
+
}
|
|
30
|
+
export declare function loadConnection(env?: NodeJS.ProcessEnv): AppilotConnection;
|
|
31
|
+
/** Which transport the process serves. Local stdio unless asked otherwise. */
|
|
32
|
+
export type TransportMode = 'stdio' | 'http';
|
|
33
|
+
export declare function resolveTransport(argv?: string[], env?: NodeJS.ProcessEnv): TransportMode;
|
|
34
|
+
/**
|
|
35
|
+
* Settings for the remote (Streamable HTTP) deployment.
|
|
36
|
+
*
|
|
37
|
+
* The remote service holds NO Appilot credential of its own: every caller
|
|
38
|
+
* arrives with their own service token, wrapped in an OAuth access token. What
|
|
39
|
+
* it does hold is one signing/encryption secret, which is what makes the OAuth
|
|
40
|
+
* authorization server stateless (see remote/oauth.ts). A deployment therefore
|
|
41
|
+
* needs three things and no database.
|
|
42
|
+
*/
|
|
43
|
+
export interface RemoteConfig {
|
|
44
|
+
/** Port to listen on. Cloud Run supplies PORT. */
|
|
45
|
+
port: number;
|
|
46
|
+
/** Public origin the clients reach, e.g. https://mcp.appilot.space. */
|
|
47
|
+
publicUrl: URL;
|
|
48
|
+
/** Secret behind the stateless OAuth tokens. Rotating it logs every client out. */
|
|
49
|
+
secret: string;
|
|
50
|
+
/** Hosts accepted in the Host header (DNS-rebinding protection). */
|
|
51
|
+
allowedHosts: string[];
|
|
52
|
+
/** The single Appilot backend this deployment serves. Never client-supplied: a
|
|
53
|
+
* caller-chosen base URL would turn the service into an SSRF relay. */
|
|
54
|
+
baseUrl: string;
|
|
55
|
+
/** Customer-facing docs root, published in the OAuth metadata a client reads
|
|
56
|
+
* before it shows the consent screen. Configurable because the docs host
|
|
57
|
+
* moves independently of the code that links to it. */
|
|
58
|
+
docsUrl: URL;
|
|
59
|
+
}
|
|
60
|
+
/** Where the docs live when a deployment does not say otherwise. */
|
|
61
|
+
export declare const DEFAULT_DOCS_URL = "https://docs.appilot.space";
|
|
62
|
+
export declare class RemoteConfigError extends Error {
|
|
63
|
+
}
|
|
64
|
+
export declare function loadRemoteConfig(env?: NodeJS.ProcessEnv): RemoteConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection profile for the Appilot MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint-agnostic by design: the same server binary talks to a cloud tenant or
|
|
5
|
+
* an on-premise / sovereign instance purely by pointing APPILOT_BASE_URL at it.
|
|
6
|
+
* Auth is a scoped service token (appilot_pat_…); no token is needed for the
|
|
7
|
+
* public capabilities/health probes. Nothing here is hard-coded to a host.
|
|
8
|
+
*/
|
|
9
|
+
function trimTrailingSlash(url) {
|
|
10
|
+
return url.replace(/\/+$/, '');
|
|
11
|
+
}
|
|
12
|
+
export function loadConnection(env = process.env) {
|
|
13
|
+
const baseUrl = env.APPILOT_BASE_URL?.trim();
|
|
14
|
+
const defaultAppId = env.APPILOT_APP_ID ? Number(env.APPILOT_APP_ID) : undefined;
|
|
15
|
+
return {
|
|
16
|
+
baseUrl: baseUrl ? trimTrailingSlash(baseUrl) : undefined,
|
|
17
|
+
token: env.APPILOT_PAT?.trim() || undefined,
|
|
18
|
+
defaultAppId: Number.isFinite(defaultAppId) ? defaultAppId : undefined,
|
|
19
|
+
soakStorageStatePath: env.APPILOT_SOAK_STORAGE_STATE?.trim() || undefined,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function resolveTransport(argv = process.argv.slice(2), env = process.env) {
|
|
23
|
+
if (argv.includes('--http'))
|
|
24
|
+
return 'http';
|
|
25
|
+
return env.APPILOT_MCP_TRANSPORT?.trim() === 'http' ? 'http' : 'stdio';
|
|
26
|
+
}
|
|
27
|
+
/** Where the docs live when a deployment does not say otherwise. */
|
|
28
|
+
export const DEFAULT_DOCS_URL = 'https://docs.appilot.space';
|
|
29
|
+
export class RemoteConfigError extends Error {
|
|
30
|
+
}
|
|
31
|
+
export function loadRemoteConfig(env = process.env) {
|
|
32
|
+
const baseUrl = env.APPILOT_BASE_URL?.trim();
|
|
33
|
+
if (!baseUrl) {
|
|
34
|
+
throw new RemoteConfigError('APPILOT_BASE_URL is required in HTTP mode: it names the one Appilot backend this deployment serves.');
|
|
35
|
+
}
|
|
36
|
+
const rawPublic = env.APPILOT_MCP_PUBLIC_URL?.trim();
|
|
37
|
+
if (!rawPublic) {
|
|
38
|
+
throw new RemoteConfigError('APPILOT_MCP_PUBLIC_URL is required in HTTP mode (for example https://mcp.appilot.space). OAuth metadata and redirects are absolute, so the service must know its own public origin.');
|
|
39
|
+
}
|
|
40
|
+
let publicUrl;
|
|
41
|
+
try {
|
|
42
|
+
publicUrl = new URL(rawPublic);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new RemoteConfigError(`APPILOT_MCP_PUBLIC_URL is not a valid URL: ${rawPublic}`);
|
|
46
|
+
}
|
|
47
|
+
if (publicUrl.protocol !== 'https:' && publicUrl.hostname !== 'localhost' && publicUrl.hostname !== '127.0.0.1') {
|
|
48
|
+
throw new RemoteConfigError('APPILOT_MCP_PUBLIC_URL must use https (localhost excepted for development). OAuth 2.1 refuses plaintext issuers.');
|
|
49
|
+
}
|
|
50
|
+
const secret = env.APPILOT_MCP_OAUTH_SECRET?.trim();
|
|
51
|
+
if (!secret || secret.length < 32) {
|
|
52
|
+
throw new RemoteConfigError('APPILOT_MCP_OAUTH_SECRET is required in HTTP mode and must be at least 32 characters (openssl rand -base64 32).');
|
|
53
|
+
}
|
|
54
|
+
const port = Number(env.PORT ?? env.APPILOT_MCP_PORT ?? 8080);
|
|
55
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
56
|
+
throw new RemoteConfigError(`PORT is not a valid port number: ${env.PORT ?? env.APPILOT_MCP_PORT}`);
|
|
57
|
+
}
|
|
58
|
+
const extraHosts = (env.APPILOT_MCP_ALLOWED_HOSTS ?? '')
|
|
59
|
+
.split(',')
|
|
60
|
+
.map(h => h.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const rawDocs = env.APPILOT_MCP_DOCS_URL?.trim() || DEFAULT_DOCS_URL;
|
|
63
|
+
let docsUrl;
|
|
64
|
+
try {
|
|
65
|
+
docsUrl = new URL(rawDocs);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new RemoteConfigError(`APPILOT_MCP_DOCS_URL is not a valid URL: ${rawDocs}`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
port,
|
|
72
|
+
publicUrl,
|
|
73
|
+
secret,
|
|
74
|
+
allowedHosts: [publicUrl.host, ...extraHosts],
|
|
75
|
+
baseUrl: trimTrailingSlash(baseUrl),
|
|
76
|
+
docsUrl,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter: ConfigBundle entities -> ConfigSnapshot.
|
|
3
|
+
*
|
|
4
|
+
* Lets `runHealthContract` gate a portable bundle (the backend import path
|
|
5
|
+
* and the MCP import tool both use it) with the exact same lints that run
|
|
6
|
+
* over a live snapshot. Lives in the contract dir because it maps INTO the
|
|
7
|
+
* contract's `ConfigSnapshot` types; the bundle shape itself is owned by
|
|
8
|
+
* `appilot-shared/config-bundle`.
|
|
9
|
+
*/
|
|
10
|
+
import type { ConfigBundleEntities } from 'appilot-shared/config-bundle';
|
|
11
|
+
import type { ConfigSnapshot } from './types.js';
|
|
12
|
+
export declare function snapshotFromBundle(entities: ConfigBundleEntities, expectedLocales: string[]): ConfigSnapshot;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Collapse a bundle translations map into `{ locale -> value }` for one field. */
|
|
2
|
+
function localizedField(translations, field) {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const [locale, fields] of Object.entries(translations)) {
|
|
5
|
+
const value = fields?.[field];
|
|
6
|
+
if (typeof value === 'string' && value.length)
|
|
7
|
+
out[locale] = value;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
export function snapshotFromBundle(entities, expectedLocales) {
|
|
12
|
+
const views = entities.views.map(v => ({
|
|
13
|
+
slug: v.slug,
|
|
14
|
+
path: v.path,
|
|
15
|
+
name: localizedField(v.translations, 'name'),
|
|
16
|
+
}));
|
|
17
|
+
const controls = entities.controls.map(c => ({
|
|
18
|
+
semantic_id: c.semantic_id,
|
|
19
|
+
locator_type: c.locator_type,
|
|
20
|
+
locator: c.locator,
|
|
21
|
+
view_path: c.view_path,
|
|
22
|
+
scope: c.scope,
|
|
23
|
+
}));
|
|
24
|
+
// A form's fields are the controls declaring membership via `form`.
|
|
25
|
+
const fieldsByForm = new Map();
|
|
26
|
+
for (const c of entities.controls) {
|
|
27
|
+
if (!c.form)
|
|
28
|
+
continue;
|
|
29
|
+
(fieldsByForm.get(c.form.form) ?? fieldsByForm.set(c.form.form, []).get(c.form.form)).push(c.semantic_id);
|
|
30
|
+
}
|
|
31
|
+
const forms = entities.forms.map(f => ({
|
|
32
|
+
semantic_id: f.semantic_id,
|
|
33
|
+
field_ids: fieldsByForm.get(f.semantic_id) ?? [],
|
|
34
|
+
submit_control_id: f.submit_control,
|
|
35
|
+
entry_control_id: f.entry_control,
|
|
36
|
+
}));
|
|
37
|
+
const actionPlans = entities.action_plans.map(p => {
|
|
38
|
+
const step_narratives = {};
|
|
39
|
+
for (const [locale, t] of Object.entries(p.translations)) {
|
|
40
|
+
if (Array.isArray(t?.step_narratives)) {
|
|
41
|
+
step_narratives[locale] = t.step_narratives;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
semantic_id: p.semantic_id,
|
|
46
|
+
sections: p.sections,
|
|
47
|
+
form_values: p.form_values,
|
|
48
|
+
name: localizedField(p.translations, 'name'),
|
|
49
|
+
description: localizedField(p.translations, 'description'),
|
|
50
|
+
step_narratives: Object.keys(step_narratives).length ? step_narratives : undefined,
|
|
51
|
+
is_active: p.is_active,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const zones = entities.zones.map(z => ({ semantic_id: z.semantic_id }));
|
|
55
|
+
const knowledge = entities.knowledge_content.map(k => ({
|
|
56
|
+
id: k.group,
|
|
57
|
+
scope: k.scope,
|
|
58
|
+
bodies: k.bodies.map(b => ({
|
|
59
|
+
language: b.language ?? '',
|
|
60
|
+
text: String(b.general_info ?? b.description ?? b.title ?? ''),
|
|
61
|
+
title: b.title,
|
|
62
|
+
})),
|
|
63
|
+
}));
|
|
64
|
+
return { expectedLocales, views, controls, forms, zones, actionPlans, knowledge };
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Appilot config health contract.
|
|
3
|
+
*
|
|
4
|
+
* A set of pure lints over a `ConfigSnapshot` that encode what a WELL-FORMED
|
|
5
|
+
* content-model configuration looks like. Every lint traces to a real failure
|
|
6
|
+
* mode observed when apps are hand-authored (see the BeKnow `create-task`
|
|
7
|
+
* audit). The marker + identifier checks REUSE the canonical validators from
|
|
8
|
+
* `appilot-shared` so the contract can never drift from the runtime:
|
|
9
|
+
* - `validateActionPlanMarkers` (the same trust boundary the backend applies)
|
|
10
|
+
* - `IDENTIFIER_KINDS` (the single source of truth for identifier shape)
|
|
11
|
+
*
|
|
12
|
+
* Contract docs: docs/content-model/config-health-contract.md.
|
|
13
|
+
*/
|
|
14
|
+
import type { ConfigSnapshot, HealthReport } from './types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Run the full config health contract over a snapshot. Findings are returned
|
|
17
|
+
* most-severe first, so the report reads like the manual audit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runHealthContract(snap: ConfigSnapshot): HealthReport;
|