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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +133 -27
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +173 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/config.d.ts +21 -0
  11. package/dist/config.js +6 -0
  12. package/dist/contract/healthContract.js +31 -4
  13. package/dist/index.bundle.js +2111 -826
  14. package/dist/index.d.ts +7 -1
  15. package/dist/index.js +22 -4
  16. package/dist/manifest.d.ts +14 -2
  17. package/dist/manifest.js +31 -9
  18. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  19. package/dist/public-marketplace/README.md +23 -0
  20. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  21. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  22. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  23. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  24. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  25. package/dist/redaction.d.ts +51 -0
  26. package/dist/redaction.js +59 -0
  27. package/dist/remote/consent.d.ts +10 -2
  28. package/dist/remote/consent.js +16 -6
  29. package/dist/remote/consentMessages.d.ts +6 -1
  30. package/dist/remote/consentMessages.js +15 -6
  31. package/dist/remote/httpServer.d.ts +10 -0
  32. package/dist/remote/httpServer.js +126 -40
  33. package/dist/remote/oauth.d.ts +10 -1
  34. package/dist/remote/oauth.js +29 -11
  35. package/dist/scaffold.d.ts +68 -6
  36. package/dist/scaffold.js +424 -97
  37. package/dist/server.js +175 -18
  38. package/dist/userClient.d.ts +213 -0
  39. package/dist/userClient.js +400 -0
  40. package/dist/userServer.d.ts +47 -0
  41. package/dist/userServer.js +248 -0
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/examples/app.appilot.json +212 -0
  45. package/mcpb/manifest.json +117 -21
  46. package/package.json +5 -3
  47. 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
- throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}`, res.status, body);
119
+ const credential = res.status === 401 || res.status === 403 ? ` ${credentialAdvice(!!this.conn.token)}` : '';
120
+ throw new AppilotApiError(`${init.method ?? 'GET'} ${path} failed: ${describeError(body, res)}${credential}`, res.status, body);
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.form_values)) {
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 entry.fields) {
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.form_values).length > 0;
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.form_values[form.semantic_id]?.fields?.length ?? 0) > 0;
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}`,