appilot-mcp 0.2.1 → 0.3.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 +74 -16
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +171 -0
- package/dist/client.d.ts +45 -1
- package/dist/client.js +74 -1
- package/dist/contract/healthContract.js +31 -4
- package/dist/index.bundle.js +820 -142
- package/dist/index.js +7 -0
- 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/remote/consent.d.ts +10 -2
- package/dist/remote/consent.js +15 -6
- package/dist/remote/consentMessages.d.ts +4 -1
- package/dist/remote/consentMessages.js +9 -6
- package/dist/remote/httpServer.js +2 -2
- package/dist/remote/oauth.js +9 -9
- package/dist/scaffold.d.ts +68 -6
- package/dist/scaffold.js +424 -97
- package/dist/server.js +175 -18
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-configurator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterKnow GmbH
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -55,23 +55,58 @@ because each caller brings theirs.
|
|
|
55
55
|
| `APPILOT_MCP_OAUTH_SECRET` | yes | At least 32 characters (`openssl rand -base64 32`). Everything the authorization server issues is sealed with it, so rotating it disconnects every client. |
|
|
56
56
|
| `PORT` | no | Defaults to 8080. Cloud Run supplies it. |
|
|
57
57
|
| `APPILOT_MCP_ALLOWED_HOSTS` | no | Extra `Host` values to accept, comma separated, for a proxy or a platform-assigned hostname. The public origin is always accepted. |
|
|
58
|
+
| `APPILOT_MCP_DOCS_URL` | no | Where the protected-resource metadata points a client for documentation. Defaults to the public docs site. |
|
|
59
|
+
| `APPILOT_MCP_BACKOFFICE_URL` | no | The Backoffice origin consent screens link to, and the only origin account approval is accepted from. Must be HTTPS (localhost excepted). Defaults to `https://backoffice.appilot.space` when the instance is the Appilot cloud. |
|
|
60
|
+
| `APPILOT_MCP_HANDOFF_SECRET` | no | Enables account approval instead of pasting a service token. At least 32 characters, and it must match the backend's. Separate from the OAuth secret, and it requires `APPILOT_MCP_BACKOFFICE_URL`. |
|
|
58
61
|
|
|
59
62
|
## Tools
|
|
60
63
|
|
|
64
|
+
Twenty-five, in the order the server registers them. `test/toolSurface.test.ts`
|
|
65
|
+
holds this table, `mcpb/manifest.json` and the registered tools to one list.
|
|
66
|
+
|
|
61
67
|
| Tool | Purpose |
|
|
62
68
|
|------|---------|
|
|
63
|
-
| `capabilities` | Version
|
|
69
|
+
| `capabilities` | Version, migration level and the closed vocabularies this instance accepts (negotiate before configuring) |
|
|
64
70
|
| `read_config` | Normalized snapshot of an app's content model |
|
|
65
71
|
| `validate_config` | Severity-ranked findings against the health contract (local + server echo) |
|
|
66
72
|
| `entity_template` | A valid skeleton per entity kind, carrying the closed enums this instance accepts |
|
|
67
73
|
| `create_entity` / `update_entity` / `delete_entity` | Authoring across all eight kinds (server re-validates the trust boundary) |
|
|
68
74
|
| `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 |
|
|
71
75
|
| `export_config` / `import_config` | Whole-app ConfigBundle round trip, dry-run first, merge or replace |
|
|
76
|
+
| `whoami` | Which organization, which app and which scopes the credential reaches |
|
|
77
|
+
| `create_app` | Provision the app, its domains and a widget key, with the snippets to paste in |
|
|
78
|
+
| `list_apps` | What this organization has provisioned, with each domain's verification status |
|
|
79
|
+
| `list_widget_keys` | The keys already minted, by label and prefix. No raw key, no secret |
|
|
80
|
+
| `verify_domain` | Where a domain's DNS verification stands, and the TXT record it needs |
|
|
81
|
+
| `integration_snippet` | The script tag and boot call for an app that already exists. Pure, no scope |
|
|
82
|
+
| `plan_manifest` / `apply_manifest` | A whole tenant from one versioned file, plan before apply |
|
|
83
|
+
| `scaffold_integration` | The host application's source: identity relay, widget boot, a client action |
|
|
84
|
+
| `verify_integration` | Load the running page and report what is actually true |
|
|
72
85
|
| `soak_selectors` | Headless-browser check that each control selector resolves on the live page (needs Playwright) |
|
|
86
|
+
| `inspect_page` | Read a page and rank locator candidates by whether they survive the next render |
|
|
87
|
+
| `scaffold_agent_first` | The artifacts one capability needs to be operable by the agent |
|
|
73
88
|
| `report_feedback` / `list_feedback` | Report a gap or a defect in Appilot, and see what this organization has already raised |
|
|
74
89
|
|
|
90
|
+
## The command line
|
|
91
|
+
|
|
92
|
+
The package is an MCP server first, and a small CLI for the things a pipeline
|
|
93
|
+
needs without an MCP client in front of them.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx appilot-mcp --help
|
|
97
|
+
npx appilot-mcp --version
|
|
98
|
+
npx appilot-mcp install-skill --claude # or --codex, --cursor, --gemini
|
|
99
|
+
npx appilot-mcp plan app.appilot.json # preview, writes nothing
|
|
100
|
+
npx appilot-mcp apply app.appilot.json # apply the plan it just printed
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`plan` and `apply` read the same environment the stdio server does, and `apply`
|
|
104
|
+
runs the plan itself and passes its own token, so the preview cannot be skipped.
|
|
105
|
+
`install-skill` copies the bundled `app-configurator` skill into the client's
|
|
106
|
+
skills directory, which is what makes the skill reachable from a bare `npx`
|
|
107
|
+
install: inside the npm cache no client looks for it.
|
|
108
|
+
|
|
109
|
+
|
|
75
110
|
## Distribution artifacts
|
|
76
111
|
|
|
77
112
|
One package carries a tool-neutral implementation plus thin discovery adapters:
|
|
@@ -83,6 +118,8 @@ One package carries a tool-neutral implementation plus thin discovery adapters:
|
|
|
83
118
|
| `.codex-plugin/plugin.json` | Codex plugin |
|
|
84
119
|
| `.claude-plugin/plugin.json` | Claude Code plugin |
|
|
85
120
|
| `mcpb/manifest.json` | Claude Desktop bundle manifest, packed by `pnpm build:mcpb` |
|
|
121
|
+
| `examples/app.appilot.json` | A complete app manifest to copy, held valid by `test/exampleManifest.test.ts` |
|
|
122
|
+
| `.claude-plugin/public-marketplace/` | The public marketplace repository, prepared and not published |
|
|
86
123
|
| `Dockerfile` | The remote HTTP service |
|
|
87
124
|
| `dist/index.bundle.js` | Self-contained Node.js MCP server, both transports |
|
|
88
125
|
|
|
@@ -113,10 +150,20 @@ task after installation so the new tool surface is loaded.
|
|
|
113
150
|
|
|
114
151
|
### Claude Code
|
|
115
152
|
|
|
116
|
-
|
|
117
|
-
plugin
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
```
|
|
154
|
+
/plugin marketplace add /absolute/path/to/app-pilot
|
|
155
|
+
/plugin install app-configurator@appilot
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Claude's plugin settings then prompt for the backend URL, the service token and
|
|
159
|
+
an optional app id. The token is declared sensitive, so Claude stores it in
|
|
160
|
+
secure credential storage.
|
|
161
|
+
|
|
162
|
+
There is no published marketplace repository yet: `appilot/appilot-plugins` does
|
|
163
|
+
not exist on GitHub, so a `/plugin marketplace add appilot/appilot-plugins`
|
|
164
|
+
anywhere is wrong until someone publishes it. The tree that repository would
|
|
165
|
+
carry is prepared in `.claude-plugin/public-marketplace/`, along with the human
|
|
166
|
+
steps, and `pnpm build:marketplace` assembles it.
|
|
120
167
|
|
|
121
168
|
### Claude Desktop
|
|
122
169
|
|
|
@@ -152,17 +199,21 @@ instance needs no public listener.
|
|
|
152
199
|
|
|
153
200
|
### Cursor
|
|
154
201
|
|
|
155
|
-
|
|
156
|
-
|
|
202
|
+
```bash
|
|
203
|
+
npx -y appilot-mcp install-skill --cursor
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Then add the server to `~/.cursor/mcp.json`.
|
|
157
207
|
|
|
158
208
|
### Antigravity
|
|
159
209
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- Antigravity CLI: `~/.gemini/antigravity-cli/skills/app-configurator`
|
|
210
|
+
```bash
|
|
211
|
+
npx -y appilot-mcp install-skill --gemini
|
|
212
|
+
```
|
|
164
213
|
|
|
165
|
-
|
|
214
|
+
That writes `~/.gemini/config/skills/app-configurator`, which is where the IDE
|
|
215
|
+
looks. The CLI reads `~/.gemini/antigravity-cli/skills/`, so pass `--dir` for
|
|
216
|
+
that one. Register the stdio server in `~/.gemini/config/mcp_config.json`.
|
|
166
217
|
|
|
167
218
|
### Any stdio MCP client
|
|
168
219
|
|
|
@@ -184,8 +235,15 @@ Point the client at the built bundle:
|
|
|
184
235
|
}
|
|
185
236
|
```
|
|
186
237
|
|
|
187
|
-
|
|
188
|
-
`
|
|
238
|
+
From npm, with no checkout at all, replace `node` and the bundle path with
|
|
239
|
+
`npx` and `-y appilot-mcp`, then install the skill the package ships:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
npx -y appilot-mcp install-skill --claude # or --codex, --cursor, --gemini
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Without that step the skill stays in the npm cache, where no client looks, and
|
|
246
|
+
the tools arrive with no procedure around them.
|
|
189
247
|
|
|
190
248
|
Do not commit service tokens. Use the MCP client's secure secret storage or
|
|
191
249
|
environment forwarding when available.
|
|
Binary file
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line around the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* `npx appilot-mcp` starts the stdio server, which is what an MCP client wants
|
|
5
|
+
* and what nothing else does. Everything here exists because a person or a
|
|
6
|
+
* pipeline typed something else and got a server waiting silently on stdin:
|
|
7
|
+
* `--help` and `--version` did that, and so did every attempt to apply a
|
|
8
|
+
* manifest from CI, which had to script an MCP client to reach a plan and an
|
|
9
|
+
* apply that are two ordinary function calls.
|
|
10
|
+
*
|
|
11
|
+
* `install-skill` is the other half of the same problem. The npm tarball
|
|
12
|
+
* carries `skills/app-configurator`, and it lands in the npm cache, where no
|
|
13
|
+
* client looks. Copying it to the client's own skills directory is the step
|
|
14
|
+
* that was documented only as a marketplace command pointing at a repository
|
|
15
|
+
* that does not exist.
|
|
16
|
+
*/
|
|
17
|
+
export declare const HELP = "appilot-mcp 0.3.0\n\nThe Appilot MCP server: read, validate, fix and provision an Appilot app.\n\nUSAGE\n appilot-mcp Start the MCP server on stdio (what a client launches)\n appilot-mcp --http Start the remote HTTP service instead\n appilot-mcp plan <file> Preview an appilot.app-manifest. Writes nothing\n appilot-mcp apply <file> Plan, then apply the same manifest\n appilot-mcp install-skill Copy the app-configurator skill to an agent client\n appilot-mcp --help | --version\n\nOPTIONS\n apply --mode merge|replace How the config half is imported. Default merge\n --allow-unhealthy Import a bundle the health gate would refuse\n install-skill --claude | --codex | --cursor | --gemini | --dir <path>\n\nENVIRONMENT\n APPILOT_BASE_URL Your instance, e.g. https://api.appilot.space or http://localhost:6001\n APPILOT_PAT A scoped service token from the Backoffice, under Service tokens\n APPILOT_APP_ID Optional default app id\n\nDocs: https://docs.appilot.space/docs/developers/configure-with-ai/overview";
|
|
18
|
+
/** Where each client looks for a skill. One directory per client, all under $HOME. */
|
|
19
|
+
export declare const SKILL_TARGETS: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* The bundled skill directory, resolved from the running file.
|
|
22
|
+
*
|
|
23
|
+
* `dist/index.bundle.js` and `skills/` are siblings under the package root in
|
|
24
|
+
* the tarball and in the workspace, so one hop up is the answer in both. The
|
|
25
|
+
* fallback covers a deeper build output rather than guessing silently.
|
|
26
|
+
*/
|
|
27
|
+
export declare function bundledSkillDir(from?: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Handle everything that is not "start a server".
|
|
30
|
+
*
|
|
31
|
+
* Returns null when the argv asks for a server, so the entry point stays the
|
|
32
|
+
* transport switch it was.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runCommand(argv: string[]): Promise<number | null>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line around the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* `npx appilot-mcp` starts the stdio server, which is what an MCP client wants
|
|
5
|
+
* and what nothing else does. Everything here exists because a person or a
|
|
6
|
+
* pipeline typed something else and got a server waiting silently on stdin:
|
|
7
|
+
* `--help` and `--version` did that, and so did every attempt to apply a
|
|
8
|
+
* manifest from CI, which had to script an MCP client to reach a plan and an
|
|
9
|
+
* apply that are two ordinary function calls.
|
|
10
|
+
*
|
|
11
|
+
* `install-skill` is the other half of the same problem. The npm tarball
|
|
12
|
+
* carries `skills/app-configurator`, and it lands in the npm cache, where no
|
|
13
|
+
* client looks. Copying it to the client's own skills directory is the step
|
|
14
|
+
* that was documented only as a marketplace command pointing at a repository
|
|
15
|
+
* that does not exist.
|
|
16
|
+
*/
|
|
17
|
+
import { cpSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { SERVER_VERSION } from './version.js';
|
|
22
|
+
import { loadConnection } from './config.js';
|
|
23
|
+
import { AppilotClient } from './client.js';
|
|
24
|
+
import { applyManifest, parseManifest, planManifest } from './manifest.js';
|
|
25
|
+
export const HELP = `appilot-mcp ${SERVER_VERSION}
|
|
26
|
+
|
|
27
|
+
The Appilot MCP server: read, validate, fix and provision an Appilot app.
|
|
28
|
+
|
|
29
|
+
USAGE
|
|
30
|
+
appilot-mcp Start the MCP server on stdio (what a client launches)
|
|
31
|
+
appilot-mcp --http Start the remote HTTP service instead
|
|
32
|
+
appilot-mcp plan <file> Preview an appilot.app-manifest. Writes nothing
|
|
33
|
+
appilot-mcp apply <file> Plan, then apply the same manifest
|
|
34
|
+
appilot-mcp install-skill Copy the app-configurator skill to an agent client
|
|
35
|
+
appilot-mcp --help | --version
|
|
36
|
+
|
|
37
|
+
OPTIONS
|
|
38
|
+
apply --mode merge|replace How the config half is imported. Default merge
|
|
39
|
+
--allow-unhealthy Import a bundle the health gate would refuse
|
|
40
|
+
install-skill --claude | --codex | --cursor | --gemini | --dir <path>
|
|
41
|
+
|
|
42
|
+
ENVIRONMENT
|
|
43
|
+
APPILOT_BASE_URL Your instance, e.g. https://api.appilot.space or http://localhost:6001
|
|
44
|
+
APPILOT_PAT A scoped service token from the Backoffice, under Service tokens
|
|
45
|
+
APPILOT_APP_ID Optional default app id
|
|
46
|
+
|
|
47
|
+
Docs: https://docs.appilot.space/docs/developers/configure-with-ai/overview`;
|
|
48
|
+
/** Where each client looks for a skill. One directory per client, all under $HOME. */
|
|
49
|
+
export const SKILL_TARGETS = {
|
|
50
|
+
claude: join('.claude', 'skills'),
|
|
51
|
+
codex: join('.codex', 'skills'),
|
|
52
|
+
cursor: join('.cursor', 'skills'),
|
|
53
|
+
gemini: join('.gemini', 'config', 'skills'),
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The bundled skill directory, resolved from the running file.
|
|
57
|
+
*
|
|
58
|
+
* `dist/index.bundle.js` and `skills/` are siblings under the package root in
|
|
59
|
+
* the tarball and in the workspace, so one hop up is the answer in both. The
|
|
60
|
+
* fallback covers a deeper build output rather than guessing silently.
|
|
61
|
+
*/
|
|
62
|
+
export function bundledSkillDir(from = fileURLToPath(import.meta.url)) {
|
|
63
|
+
const candidates = [
|
|
64
|
+
join(from, '..', '..', 'skills', 'app-configurator'),
|
|
65
|
+
join(from, '..', '..', '..', 'skills', 'app-configurator'),
|
|
66
|
+
];
|
|
67
|
+
return candidates.find(candidate => existsSync(join(candidate, 'SKILL.md'))) ?? null;
|
|
68
|
+
}
|
|
69
|
+
function installSkill(args) {
|
|
70
|
+
const source = bundledSkillDir();
|
|
71
|
+
if (!source) {
|
|
72
|
+
process.stderr.write('[appilot-mcp] the bundled skill was not found next to this install. Reinstall the package (npm i -g appilot-mcp), or copy skills/app-configurator from the repository by hand.\n');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const dirFlag = args.indexOf('--dir');
|
|
76
|
+
let destParent;
|
|
77
|
+
if (dirFlag !== -1) {
|
|
78
|
+
const explicit = args[dirFlag + 1];
|
|
79
|
+
if (!explicit) {
|
|
80
|
+
process.stderr.write('[appilot-mcp] --dir needs a path.\n');
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
destParent = explicit;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const client = Object.keys(SKILL_TARGETS).find(name => args.includes(`--${name}`));
|
|
87
|
+
if (!client) {
|
|
88
|
+
process.stderr.write(`[appilot-mcp] name the client: ${Object.keys(SKILL_TARGETS).map(c => `--${c}`).join(', ')}, or --dir <path>.\n`);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
destParent = join(homedir(), SKILL_TARGETS[client]);
|
|
92
|
+
}
|
|
93
|
+
const dest = join(destParent, 'app-configurator');
|
|
94
|
+
mkdirSync(destParent, { recursive: true });
|
|
95
|
+
cpSync(source, dest, { recursive: true });
|
|
96
|
+
process.stdout.write(`Installed the app-configurator skill to ${dest}\nStart a new session so the client loads it.\n`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
function readManifestFile(path) {
|
|
100
|
+
if (!path)
|
|
101
|
+
throw new Error('Name the manifest file: appilot-mcp plan app.appilot.json');
|
|
102
|
+
if (!existsSync(path))
|
|
103
|
+
throw new Error(`No such file: ${path}`);
|
|
104
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
async function runPlan(args) {
|
|
107
|
+
const conn = loadConnection();
|
|
108
|
+
const client = new AppilotClient(conn);
|
|
109
|
+
const manifest = parseManifest(readManifestFile(args[0]));
|
|
110
|
+
const plan = await planManifest(client, manifest, id => {
|
|
111
|
+
const resolved = id ?? conn.defaultAppId;
|
|
112
|
+
return resolved != null && Number.isFinite(resolved) ? resolved : null;
|
|
113
|
+
});
|
|
114
|
+
process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Apply runs its own plan and passes the token it produced.
|
|
119
|
+
*
|
|
120
|
+
* The token binds an apply to a preview of the same bytes, and a CLI that made
|
|
121
|
+
* the caller carry it between two commands would only teach them to store it.
|
|
122
|
+
* Planning here keeps the property (the manifest cannot change between the two)
|
|
123
|
+
* and prints the preview, so the run is still auditable in a CI log.
|
|
124
|
+
*/
|
|
125
|
+
async function runApply(args) {
|
|
126
|
+
const conn = loadConnection();
|
|
127
|
+
const client = new AppilotClient(conn);
|
|
128
|
+
const manifest = parseManifest(readManifestFile(args[0]));
|
|
129
|
+
const modeFlag = args.indexOf('--mode');
|
|
130
|
+
const mode = modeFlag !== -1 ? args[modeFlag + 1] : undefined;
|
|
131
|
+
if (mode !== undefined && mode !== 'merge' && mode !== 'replace') {
|
|
132
|
+
throw new Error(`--mode is merge or replace, not ${mode}.`);
|
|
133
|
+
}
|
|
134
|
+
const plan = await planManifest(client, manifest, id => {
|
|
135
|
+
const resolved = id ?? conn.defaultAppId;
|
|
136
|
+
return resolved != null && Number.isFinite(resolved) ? resolved : null;
|
|
137
|
+
});
|
|
138
|
+
process.stderr.write(`[appilot-mcp] planned ${plan.planToken.slice(0, 12)}…\n`);
|
|
139
|
+
const result = await applyManifest(client, manifest, {
|
|
140
|
+
planToken: plan.planToken,
|
|
141
|
+
mode,
|
|
142
|
+
expectedCurrentHash: plan.config?.expectedCurrentHash,
|
|
143
|
+
allowUnhealthy: args.includes('--allow-unhealthy'),
|
|
144
|
+
});
|
|
145
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Handle everything that is not "start a server".
|
|
150
|
+
*
|
|
151
|
+
* Returns null when the argv asks for a server, so the entry point stays the
|
|
152
|
+
* transport switch it was.
|
|
153
|
+
*/
|
|
154
|
+
export async function runCommand(argv) {
|
|
155
|
+
const [command, ...rest] = argv;
|
|
156
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
157
|
+
process.stdout.write(HELP + '\n');
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
161
|
+
process.stdout.write(SERVER_VERSION + '\n');
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'install-skill')
|
|
165
|
+
return installSkill(rest);
|
|
166
|
+
if (command === 'plan')
|
|
167
|
+
return runPlan(rest);
|
|
168
|
+
if (command === 'apply')
|
|
169
|
+
return runApply(rest);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -52,10 +52,19 @@ export interface ProvisionAppResponse {
|
|
|
52
52
|
}>;
|
|
53
53
|
widgetKey?: {
|
|
54
54
|
id: number | null;
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* `blocked` is a real outcome, not an error: a LIVE key needs a verified
|
|
57
|
+
* domain, and the app and its domains are still provisioned without it.
|
|
58
|
+
* The union has to carry it or every caller reads a blocked key as a
|
|
59
|
+
* successful one.
|
|
60
|
+
*/
|
|
61
|
+
action: 'created' | 'reused' | 'updated' | 'blocked';
|
|
56
62
|
keyPrefix?: string;
|
|
57
63
|
rawKey?: string;
|
|
58
64
|
rawSecret?: string;
|
|
65
|
+
allowedDomains?: string[];
|
|
66
|
+
/** Present on `blocked`: what is missing and what to do about it. */
|
|
67
|
+
reason?: string;
|
|
59
68
|
};
|
|
60
69
|
integration: {
|
|
61
70
|
apiUrl: string | null;
|
|
@@ -91,6 +100,14 @@ export interface ProvisionedAppSummary {
|
|
|
91
100
|
verification_status: string;
|
|
92
101
|
}>;
|
|
93
102
|
}
|
|
103
|
+
/** A widget key as the org sees it back: no raw value, no secret, ever. */
|
|
104
|
+
export interface ProvisionedWidgetKey {
|
|
105
|
+
id: number;
|
|
106
|
+
name: string;
|
|
107
|
+
keyPrefix: string;
|
|
108
|
+
allowedDomains: string[];
|
|
109
|
+
isActive: boolean;
|
|
110
|
+
}
|
|
94
111
|
export declare class AppilotApiError extends Error {
|
|
95
112
|
readonly status: number;
|
|
96
113
|
readonly body?: unknown;
|
|
@@ -169,6 +186,13 @@ export declare class AppilotClient {
|
|
|
169
186
|
errors: string[];
|
|
170
187
|
}>;
|
|
171
188
|
listProvisioned(): Promise<ProvisionedAppSummary[]>;
|
|
189
|
+
/**
|
|
190
|
+
* The org's widget keys, prefixes only.
|
|
191
|
+
*
|
|
192
|
+
* A caller that cannot see the keys it already minted asks for another every
|
|
193
|
+
* run, and each one is a live credential.
|
|
194
|
+
*/
|
|
195
|
+
listWidgetKeys(): Promise<ProvisionedWidgetKey[]>;
|
|
172
196
|
provisionApp(body: ProvisionAppRequest): Promise<ProvisionAppResponse>;
|
|
173
197
|
/**
|
|
174
198
|
* Domain check: does this hostname resolve to a tenant? Public endpoint, so
|
|
@@ -180,6 +204,17 @@ export declare class AppilotClient {
|
|
|
180
204
|
* the tenant on every single run.
|
|
181
205
|
*/
|
|
182
206
|
checkDomain(domain: string): Promise<Record<string, unknown>>;
|
|
207
|
+
/**
|
|
208
|
+
* The TXT record a domain needs, and where its verification stands.
|
|
209
|
+
*
|
|
210
|
+
* These two live on the apps router rather than under `/provision`, and that
|
|
211
|
+
* router authenticates an organization SESSION. A service token is refused
|
|
212
|
+
* there today, which is why `verify_domain` falls back to the provisioning
|
|
213
|
+
* dry run for the status and says plainly what it could not do.
|
|
214
|
+
*/
|
|
215
|
+
domainVerification(domainId: number): Promise<Record<string, unknown>>;
|
|
216
|
+
/** Ask Appilot to look for the TXT record now. */
|
|
217
|
+
triggerDomainVerification(domainId: number): Promise<Record<string, unknown>>;
|
|
183
218
|
/** Who this credential is: org, app narrowing, scopes. Never a secret. */
|
|
184
219
|
whoami(): Promise<Record<string, unknown>>;
|
|
185
220
|
exportConfig(appId: number): Promise<import('appilot-shared/config-bundle').ConfigBundle>;
|
|
@@ -205,6 +240,15 @@ export declare class AppilotClient {
|
|
|
205
240
|
* languages it had never claimed to support.
|
|
206
241
|
*/
|
|
207
242
|
buildSnapshot(appId: number, expectedLocales?: string[]): Promise<ConfigSnapshot>;
|
|
243
|
+
/**
|
|
244
|
+
* Why an empty read should not be believed, or null when the credential is
|
|
245
|
+
* fine and the app genuinely has nothing.
|
|
246
|
+
*
|
|
247
|
+
* Only a 401 or a 403 counts. An instance too old to answer `whoami` at all
|
|
248
|
+
* says nothing about the token, and reporting a gap on that would be a
|
|
249
|
+
* confident false alarm on every on-premise deployment behind cloud.
|
|
250
|
+
*/
|
|
251
|
+
private credentialRefusal;
|
|
208
252
|
/**
|
|
209
253
|
* Which locales this app's configuration is expected to cover: the union of
|
|
210
254
|
* its domains' configured languages. Falls back to the trilingual baseline
|
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);
|
|
@@ -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}`,
|