appilot-mcp 0.2.1 → 0.4.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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/LICENSE +15 -0
- package/README.md +133 -27
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +173 -0
- package/dist/client.d.ts +45 -1
- package/dist/client.js +74 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +6 -0
- package/dist/contract/healthContract.js +31 -4
- package/dist/index.bundle.js +2111 -826
- package/dist/index.d.ts +7 -1
- package/dist/index.js +22 -4
- package/dist/manifest.d.ts +14 -2
- package/dist/manifest.js +31 -9
- package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
- package/dist/public-marketplace/README.md +23 -0
- package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
- package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
- package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
- package/dist/redaction.d.ts +51 -0
- package/dist/redaction.js +59 -0
- package/dist/remote/consent.d.ts +10 -2
- package/dist/remote/consent.js +16 -6
- package/dist/remote/consentMessages.d.ts +6 -1
- package/dist/remote/consentMessages.js +15 -6
- package/dist/remote/httpServer.d.ts +10 -0
- package/dist/remote/httpServer.js +126 -40
- package/dist/remote/oauth.d.ts +10 -1
- package/dist/remote/oauth.js +29 -11
- package/dist/scaffold.d.ts +68 -6
- package/dist/scaffold.js +424 -97
- package/dist/server.js +175 -18
- package/dist/userClient.d.ts +213 -0
- package/dist/userClient.js +400 -0
- package/dist/userServer.d.ts +47 -0
- package/dist/userServer.js +248 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/examples/app.appilot.json +212 -0
- package/mcpb/manifest.json +117 -21
- package/package.json +5 -3
- package/skills/app-configurator/SKILL.md +61 -19
package/dist/client.js
CHANGED
|
@@ -116,7 +116,8 @@ export class AppilotClient {
|
|
|
116
116
|
const text = await res.text();
|
|
117
117
|
const body = text ? safeJson(text) : undefined;
|
|
118
118
|
if (!res.ok) {
|
|
119
|
-
|
|
119
|
+
const credential = res.status === 401 || res.status === 403 ? ` ${credentialAdvice(!!this.conn.token)}` : '';
|
|
120
|
+
throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}${credential}`, res.status, body);
|
|
120
121
|
}
|
|
121
122
|
return body;
|
|
122
123
|
}
|
|
@@ -217,6 +218,16 @@ export class AppilotClient {
|
|
|
217
218
|
listProvisioned() {
|
|
218
219
|
return this.request('/provision/apps');
|
|
219
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The org's widget keys, prefixes only.
|
|
223
|
+
*
|
|
224
|
+
* A caller that cannot see the keys it already minted asks for another every
|
|
225
|
+
* run, and each one is a live credential.
|
|
226
|
+
*/
|
|
227
|
+
async listWidgetKeys() {
|
|
228
|
+
const body = await this.request('/provision/widget-keys');
|
|
229
|
+
return Array.isArray(body?.widgetKeys) ? body.widgetKeys : [];
|
|
230
|
+
}
|
|
220
231
|
provisionApp(body) {
|
|
221
232
|
return this.request('/provision/app', { method: 'POST', body: JSON.stringify(body) });
|
|
222
233
|
}
|
|
@@ -233,6 +244,21 @@ export class AppilotClient {
|
|
|
233
244
|
const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
|
|
234
245
|
return this.request(`/domain/check?url=${encodeURIComponent(url)}`);
|
|
235
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* The TXT record a domain needs, and where its verification stands.
|
|
249
|
+
*
|
|
250
|
+
* These two live on the apps router rather than under `/provision`, and that
|
|
251
|
+
* router authenticates an organization SESSION. A service token is refused
|
|
252
|
+
* there today, which is why `verify_domain` falls back to the provisioning
|
|
253
|
+
* dry run for the status and says plainly what it could not do.
|
|
254
|
+
*/
|
|
255
|
+
domainVerification(domainId) {
|
|
256
|
+
return this.request(`/apps/domains/${domainId}/verification`);
|
|
257
|
+
}
|
|
258
|
+
/** Ask Appilot to look for the TXT record now. */
|
|
259
|
+
triggerDomainVerification(domainId) {
|
|
260
|
+
return this.request(`/apps/domains/${domainId}/verify`, { method: 'POST' });
|
|
261
|
+
}
|
|
236
262
|
/** Who this credential is: org, app narrowing, scopes. Never a secret. */
|
|
237
263
|
whoami() {
|
|
238
264
|
return this.request('/config/whoami');
|
|
@@ -286,6 +312,17 @@ export class AppilotClient {
|
|
|
286
312
|
read('knowledge', () => this.listKnowledge(appId), mapKnowledge),
|
|
287
313
|
expectedLocales?.length ? Promise.resolve(expectedLocales) : this.resolveLocales(appId),
|
|
288
314
|
]);
|
|
315
|
+
// An empty knowledge list is the one read that can mean two things.
|
|
316
|
+
// `/knowledge/content` is an optional-auth route, so a missing or rejected
|
|
317
|
+
// bearer answers `200 []` instead of refusing, and from here that is
|
|
318
|
+
// indistinguishable from an app with no knowledge. Every knowledge lint
|
|
319
|
+
// then passes over a configuration nobody was allowed to look at, which is
|
|
320
|
+
// exactly the failure the `gaps` array exists to prevent.
|
|
321
|
+
if (knowledge.length === 0 && !gaps.some(g => g.entity === 'knowledge')) {
|
|
322
|
+
const refusal = await this.credentialRefusal();
|
|
323
|
+
if (refusal)
|
|
324
|
+
gaps.push({ entity: 'knowledge', reason: refusal });
|
|
325
|
+
}
|
|
289
326
|
return {
|
|
290
327
|
expectedLocales: locales,
|
|
291
328
|
views,
|
|
@@ -298,6 +335,29 @@ export class AppilotClient {
|
|
|
298
335
|
...(gaps.length ? { gaps } : {}),
|
|
299
336
|
};
|
|
300
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Why an empty read should not be believed, or null when the credential is
|
|
340
|
+
* fine and the app genuinely has nothing.
|
|
341
|
+
*
|
|
342
|
+
* Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
|
|
343
|
+
* says nothing about the token, and reporting a gap on that would be a
|
|
344
|
+
* confident false alarm on every on-premise deployment behind cloud.
|
|
345
|
+
*/
|
|
346
|
+
async credentialRefusal() {
|
|
347
|
+
if (!this.conn.token) {
|
|
348
|
+
return `No service token is set, so the knowledge read was anonymous and answered an empty list rather than refusing. ${credentialAdvice(false)}`;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
await this.whoami();
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
if (err instanceof AppilotApiError && (err.status === 401 || err.status === 403)) {
|
|
356
|
+
return `The knowledge read returned nothing and the credential failed its own self-check, so the empty result is not evidence of an empty knowledge base. ${credentialAdvice(true)}`;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
301
361
|
/**
|
|
302
362
|
* Which locales this app's configuration is expected to cover: the union of
|
|
303
363
|
* its domains' configured languages. Falls back to the trilingual baseline
|
|
@@ -328,6 +388,19 @@ export class AppilotClient {
|
|
|
328
388
|
return DEFAULT_EXPECTED_LOCALES;
|
|
329
389
|
}
|
|
330
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* What to do about a 401 or a 403, in the same voice as the offline errors.
|
|
393
|
+
*
|
|
394
|
+
* "Access denied. Token required." is the first thing a new developer meets and
|
|
395
|
+
* it names nothing: not the variable to set, not the screen the token comes
|
|
396
|
+
* from, not the preset to choose. The `APPILOT_BASE_URL` message next door does
|
|
397
|
+
* name all three, and it is the standard this one had to reach.
|
|
398
|
+
*/
|
|
399
|
+
function credentialAdvice(hasToken) {
|
|
400
|
+
return hasToken
|
|
401
|
+
? 'The credential was rejected. Check it in the Backoffice under Service tokens: it may be revoked, expired, or scoped to another app. A token narrowed to one app is refused on every other app in the organization.'
|
|
402
|
+
: 'No credential is set. Put a service token in APPILOT_PAT in the MCP client environment and restart the client. Mint one in the Backoffice under Service tokens: "Inspect only" to read and audit, "Edit configuration" to write, "Set up integrations" to create apps, domains and widget keys.';
|
|
403
|
+
}
|
|
331
404
|
function safeJson(text) {
|
|
332
405
|
try {
|
|
333
406
|
return JSON.parse(text);
|
package/dist/config.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface AppilotConnection {
|
|
|
11
11
|
baseUrl?: string;
|
|
12
12
|
/** Scoped service token (appilot_pat_…). Optional for read-only capability probes. */
|
|
13
13
|
token?: string;
|
|
14
|
+
/**
|
|
15
|
+
* An Appilot USER session, which is what the runtime connector authenticates
|
|
16
|
+
* with (see userClient.ts). It is deliberately a separate field from `token`:
|
|
17
|
+
* a service token names an organization and its scopes, a session names a
|
|
18
|
+
* person, and the runtime routes exist precisely so that a connector can
|
|
19
|
+
* never do more than the person whose session it holds. Sending one where the
|
|
20
|
+
* other belongs would either fail closed or, worse, widen the connector.
|
|
21
|
+
*/
|
|
22
|
+
sessionToken?: string;
|
|
14
23
|
/** Default app id for tools that omit one. */
|
|
15
24
|
defaultAppId?: number;
|
|
16
25
|
/** Optional site session for the soak tool (see soak.ts). */
|
|
@@ -42,6 +51,18 @@ export declare function loadConnection(env?: NodeJS.ProcessEnv): AppilotConnecti
|
|
|
42
51
|
/** Which transport the process serves. Local stdio unless asked otherwise. */
|
|
43
52
|
export type TransportMode = 'stdio' | 'http';
|
|
44
53
|
export declare function resolveTransport(argv?: string[], env?: NodeJS.ProcessEnv): TransportMode;
|
|
54
|
+
/**
|
|
55
|
+
* Which of the two servers a stdio process serves.
|
|
56
|
+
*
|
|
57
|
+
* `config` is Appilot Studio, the configuration surface a developer connects to
|
|
58
|
+
* and the default, because it is what every existing client launches. `runtime`
|
|
59
|
+
* is the Appilot connector, which operates an already-configured app on behalf
|
|
60
|
+
* of the person using it. One MCP process serves one server, so the choice is
|
|
61
|
+
* made here rather than inside either factory. The remote transport makes the
|
|
62
|
+
* same choice per request from the path, and never from an environment value.
|
|
63
|
+
*/
|
|
64
|
+
export type ServerSurface = 'config' | 'runtime';
|
|
65
|
+
export declare function resolveServerSurface(argv?: string[], env?: NodeJS.ProcessEnv): ServerSurface;
|
|
45
66
|
/**
|
|
46
67
|
* Settings for the remote (Streamable HTTP) deployment.
|
|
47
68
|
*
|
package/dist/config.js
CHANGED
|
@@ -15,6 +15,7 @@ export function loadConnection(env = process.env) {
|
|
|
15
15
|
return {
|
|
16
16
|
baseUrl: baseUrl ? trimTrailingSlash(baseUrl) : undefined,
|
|
17
17
|
token: env.APPILOT_PAT?.trim() || undefined,
|
|
18
|
+
sessionToken: env.APPILOT_SESSION_TOKEN?.trim() || undefined,
|
|
18
19
|
defaultAppId: Number.isFinite(defaultAppId) ? defaultAppId : undefined,
|
|
19
20
|
soakStorageStatePath: env.APPILOT_SOAK_STORAGE_STATE?.trim() || undefined,
|
|
20
21
|
};
|
|
@@ -24,6 +25,11 @@ export function resolveTransport(argv = process.argv.slice(2), env = process.env
|
|
|
24
25
|
return 'http';
|
|
25
26
|
return env.APPILOT_MCP_TRANSPORT?.trim() === 'http' ? 'http' : 'stdio';
|
|
26
27
|
}
|
|
28
|
+
export function resolveServerSurface(argv = process.argv.slice(2), env = process.env) {
|
|
29
|
+
if (argv.includes('--runtime'))
|
|
30
|
+
return 'runtime';
|
|
31
|
+
return env.APPILOT_MCP_SERVER?.trim() === 'runtime' ? 'runtime' : 'config';
|
|
32
|
+
}
|
|
27
33
|
/** Where the docs live when a deployment does not say otherwise. */
|
|
28
34
|
export const DEFAULT_DOCS_URL = 'https://docs.appilot.space';
|
|
29
35
|
export class RemoteConfigError extends Error {
|
|
@@ -31,6 +31,19 @@ const CREATE_INTENT = /\b(create|add|new|update|edit|erstell|anleg|neu|hinzu|cre
|
|
|
31
31
|
function localizedValues(text) {
|
|
32
32
|
return Object.values(text).filter((v) => typeof v === 'string');
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* `form_values` as a record, whatever arrived.
|
|
36
|
+
*
|
|
37
|
+
* The snapshot type says it is one, and a hand-authored or scaffolded plan can
|
|
38
|
+
* still carry null or a string. Reading it defensively keeps a malformed bundle
|
|
39
|
+
* a finding rather than an exception thrown out of the whole audit.
|
|
40
|
+
*/
|
|
41
|
+
function authoredFormValues(plan) {
|
|
42
|
+
const raw = plan.form_values;
|
|
43
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
44
|
+
? raw
|
|
45
|
+
: {};
|
|
46
|
+
}
|
|
34
47
|
// ---------------------------------------------------------------------------
|
|
35
48
|
// Lint 1 — marker trust boundary (reuse appilot-shared).
|
|
36
49
|
// ---------------------------------------------------------------------------
|
|
@@ -52,7 +65,7 @@ function lintMarkers(snap) {
|
|
|
52
65
|
});
|
|
53
66
|
}
|
|
54
67
|
// form_values fields must belong to their form.
|
|
55
|
-
for (const [formId, entry] of Object.entries(plan
|
|
68
|
+
for (const [formId, entry] of Object.entries(authoredFormValues(plan))) {
|
|
56
69
|
if (!formIds.has(formId)) {
|
|
57
70
|
findings.push({
|
|
58
71
|
severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
|
|
@@ -60,8 +73,22 @@ function lintMarkers(snap) {
|
|
|
60
73
|
});
|
|
61
74
|
continue;
|
|
62
75
|
}
|
|
76
|
+
// A malformed `form_values` entry is a FINDING, not a crash. The
|
|
77
|
+
// authored shape is `{ fields: [{ control_id, value }] }`, and a bundle
|
|
78
|
+
// carrying `{}` there used to take the whole contract down with
|
|
79
|
+
// "entry.fields is not iterable", so the one tool that could have named
|
|
80
|
+
// the defect answered with a stack trace instead.
|
|
81
|
+
const authored = entry;
|
|
82
|
+
if (!authored || !Array.isArray(authored.fields)) {
|
|
83
|
+
findings.push({
|
|
84
|
+
severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
|
|
85
|
+
message: `form_values["${formId}"] is not in the authored shape { fields: [{ control_id, value }] }.`,
|
|
86
|
+
recommendation: 'Write an empty fields array when there are no authored defaults.',
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
63
90
|
const fields = fieldsByForm.get(formId) ?? new Set();
|
|
64
|
-
for (const f of
|
|
91
|
+
for (const f of authored.fields) {
|
|
65
92
|
if (!fields.has(f.control_id)) {
|
|
66
93
|
findings.push({
|
|
67
94
|
severity: 'high', category: 'markers', entity: `action_plan:${plan.semantic_id}`,
|
|
@@ -84,7 +111,7 @@ function lintActionability(snap) {
|
|
|
84
111
|
const markers = planMarkers(plan);
|
|
85
112
|
const hasFormStep = markers.some(m => m.kind === 'form');
|
|
86
113
|
const hasSetValue = markers.some(m => m.kind === 'set_value' || m.kind === 'select');
|
|
87
|
-
const hasFormValues = Object.keys(plan
|
|
114
|
+
const hasFormValues = Object.keys(authoredFormValues(plan)).length > 0;
|
|
88
115
|
const entersValue = hasFormStep || hasSetValue || hasFormValues;
|
|
89
116
|
const looksLikeCreate = localizedValues({ ...plan.name, ...plan.description }).some(v => CREATE_INTENT.test(v))
|
|
90
117
|
|| CREATE_INTENT.test(plan.semantic_id);
|
|
@@ -123,7 +150,7 @@ function lintActionability(snap) {
|
|
|
123
150
|
if (!clicksSubmit)
|
|
124
151
|
continue;
|
|
125
152
|
const fillsForm = markers.some(m => m.kind === 'form' && m.id === form.semantic_id)
|
|
126
|
-
|| (plan
|
|
153
|
+
|| (authoredFormValues(plan)[form.semantic_id]?.fields?.length ?? 0) > 0;
|
|
127
154
|
if (!fillsForm) {
|
|
128
155
|
findings.push({
|
|
129
156
|
severity: 'high', category: 'actionability', entity: `action_plan:${plan.semantic_id}`,
|