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/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Appilot MCP server entry point.
|
|
4
|
+
*
|
|
5
|
+
* Two transports, one tool surface (see server.ts):
|
|
6
|
+
*
|
|
7
|
+
* stdio (default) The operator runs the process on their own machine or
|
|
8
|
+
* inside their network, and it carries their credentials in
|
|
9
|
+
* its environment. This is what Claude Code, Codex, Claude
|
|
10
|
+
* Desktop, Cursor and Antigravity launch, and the only shape
|
|
11
|
+
* that works air-gapped.
|
|
12
|
+
*
|
|
13
|
+
* http (--http) A deployed service speaking Streamable HTTP with OAuth,
|
|
14
|
+
* for the clients that cannot launch a local process:
|
|
15
|
+
* ChatGPT and claude.ai. It holds no credential of its own.
|
|
16
|
+
*
|
|
17
|
+
* Contract: docs/content-model/config-health-contract.md.
|
|
18
|
+
* Server: docs/architecture/appilot-mcp.md.
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Appilot MCP server entry point.
|
|
4
|
+
*
|
|
5
|
+
* Two transports, one tool surface (see server.ts):
|
|
6
|
+
*
|
|
7
|
+
* stdio (default) The operator runs the process on their own machine or
|
|
8
|
+
* inside their network, and it carries their credentials in
|
|
9
|
+
* its environment. This is what Claude Code, Codex, Claude
|
|
10
|
+
* Desktop, Cursor and Antigravity launch, and the only shape
|
|
11
|
+
* that works air-gapped.
|
|
12
|
+
*
|
|
13
|
+
* http (--http) A deployed service speaking Streamable HTTP with OAuth,
|
|
14
|
+
* for the clients that cannot launch a local process:
|
|
15
|
+
* ChatGPT and claude.ai. It holds no credential of its own.
|
|
16
|
+
*
|
|
17
|
+
* Contract: docs/content-model/config-health-contract.md.
|
|
18
|
+
* Server: docs/architecture/appilot-mcp.md.
|
|
19
|
+
*/
|
|
20
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
+
import { loadConnection, loadRemoteConfig, resolveTransport, RemoteConfigError } from './config.js';
|
|
22
|
+
import { createAppilotServer } from './server.js';
|
|
23
|
+
async function runStdio() {
|
|
24
|
+
const conn = loadConnection();
|
|
25
|
+
const server = createAppilotServer(conn);
|
|
26
|
+
await server.connect(new StdioServerTransport());
|
|
27
|
+
// stderr is safe for logs; stdout is the MCP transport.
|
|
28
|
+
process.stderr.write(`[appilot-mcp] connected · base=${conn.baseUrl ?? 'not-configured'} · token=${conn.token ? 'set' : 'none'}\n`);
|
|
29
|
+
}
|
|
30
|
+
async function runHttp() {
|
|
31
|
+
const { startRemote } = await import('./remote/httpServer.js');
|
|
32
|
+
await startRemote(loadRemoteConfig());
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
if (resolveTransport() === 'http') {
|
|
36
|
+
await runHttp();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await runStdio();
|
|
40
|
+
}
|
|
41
|
+
main().catch(err => {
|
|
42
|
+
// Name the missing variable and stop. A stack trace tells the operator
|
|
43
|
+
// nothing they can act on.
|
|
44
|
+
if (err instanceof RemoteConfigError) {
|
|
45
|
+
process.stderr.write(`[appilot-mcp] ${err.message}\n`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
process.stderr.write(`[appilot-mcp] fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app manifest: one declarative file that describes a whole Appilot tenant,
|
|
3
|
+
* living in the customer's repository and versioned by git.
|
|
4
|
+
*
|
|
5
|
+
* It deliberately does not invent a new format for the content model. The
|
|
6
|
+
* ConfigBundle already carries semantic identity, deterministic serialization,
|
|
7
|
+
* a content hash, secret exclusion, and a negotiated format version, and those
|
|
8
|
+
* were expensive to get right. The manifest wraps it with the three things a
|
|
9
|
+
* bundle cannot carry because they are not content: the app, its domains, and
|
|
10
|
+
* its widget keys.
|
|
11
|
+
*
|
|
12
|
+
* ```jsonc
|
|
13
|
+
* {
|
|
14
|
+
* "kind": "appilot.app-manifest",
|
|
15
|
+
* "formatVersion": "1.0",
|
|
16
|
+
* "app": { "name": "Acme Booking" },
|
|
17
|
+
* "domains": [{ "domain": "app.acme.com" }],
|
|
18
|
+
* "widgetKeys": [{ "name": "production" }],
|
|
19
|
+
* "config": { "kind": "appilot.config-bundle", ... }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Plan before apply is structural, not advisory. `planManifest` returns a
|
|
24
|
+
* `planToken` derived from the manifest's own bytes, and `applyManifest`
|
|
25
|
+
* refuses without a matching one. A model that skips the plan step cannot
|
|
26
|
+
* produce the token by guessing, and a manifest edited between the two calls
|
|
27
|
+
* produces a different token, so the apply fails rather than committing
|
|
28
|
+
* something nobody previewed.
|
|
29
|
+
*/
|
|
30
|
+
import type { AppilotClient, ProvisionAppResponse } from './client.js';
|
|
31
|
+
export declare const MANIFEST_KIND = "appilot.app-manifest";
|
|
32
|
+
export declare const MANIFEST_FORMAT_VERSION = "1.0";
|
|
33
|
+
export interface AppManifest {
|
|
34
|
+
kind?: string;
|
|
35
|
+
formatVersion?: string;
|
|
36
|
+
app: {
|
|
37
|
+
name: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
};
|
|
40
|
+
domains?: Array<{
|
|
41
|
+
domain: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
default_language?: string | null;
|
|
44
|
+
configured_languages?: string[];
|
|
45
|
+
}>;
|
|
46
|
+
widgetKeys?: Array<{
|
|
47
|
+
name?: string;
|
|
48
|
+
allowedDomains?: string[];
|
|
49
|
+
isTest?: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
/** A ConfigBundle, verbatim. Optional: provisioning alone is a valid manifest. */
|
|
52
|
+
config?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
export interface ManifestPlan {
|
|
55
|
+
planToken: string;
|
|
56
|
+
provisioning: ProvisionAppResponse;
|
|
57
|
+
config?: {
|
|
58
|
+
/** The import dry-run diff, including the health findings and currentHash. */
|
|
59
|
+
diff: unknown;
|
|
60
|
+
/** Pass this back on apply so a concurrent edit is a 409, not a silent overwrite. */
|
|
61
|
+
expectedCurrentHash?: string;
|
|
62
|
+
};
|
|
63
|
+
notes: string[];
|
|
64
|
+
}
|
|
65
|
+
/** The token that binds an apply to the plan that previewed it. */
|
|
66
|
+
export declare function manifestDigest(manifest: AppManifest): string;
|
|
67
|
+
export declare function parseManifest(input: Record<string, unknown> | string): AppManifest;
|
|
68
|
+
/**
|
|
69
|
+
* Preview everything the manifest would change. Writes nothing.
|
|
70
|
+
*
|
|
71
|
+
* Both halves run in dry-run: provisioning reports created/reused/updated per
|
|
72
|
+
* entity, and the config import returns its per-entity diff plus the health
|
|
73
|
+
* findings over the PROSPECTIVE post-apply state.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planManifest(client: AppilotClient, manifest: AppManifest, resolveAppId: (id?: number) => number | null): Promise<ManifestPlan>;
|
|
76
|
+
/**
|
|
77
|
+
* Apply a previously planned manifest.
|
|
78
|
+
*
|
|
79
|
+
* `planToken` must match the manifest being applied. This is the structural
|
|
80
|
+
* version of "always dry-run first": the guidance cannot be skipped, because
|
|
81
|
+
* the token is unobtainable without the plan call and changes with the
|
|
82
|
+
* manifest.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyManifest(client: AppilotClient, manifest: AppManifest, options: {
|
|
85
|
+
planToken: string;
|
|
86
|
+
mode?: 'merge' | 'replace';
|
|
87
|
+
expectedCurrentHash?: string;
|
|
88
|
+
allowUnhealthy?: boolean;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
provisioning: ProvisionAppResponse;
|
|
91
|
+
config?: unknown;
|
|
92
|
+
notes: string[];
|
|
93
|
+
}>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app manifest: one declarative file that describes a whole Appilot tenant,
|
|
3
|
+
* living in the customer's repository and versioned by git.
|
|
4
|
+
*
|
|
5
|
+
* It deliberately does not invent a new format for the content model. The
|
|
6
|
+
* ConfigBundle already carries semantic identity, deterministic serialization,
|
|
7
|
+
* a content hash, secret exclusion, and a negotiated format version, and those
|
|
8
|
+
* were expensive to get right. The manifest wraps it with the three things a
|
|
9
|
+
* bundle cannot carry because they are not content: the app, its domains, and
|
|
10
|
+
* its widget keys.
|
|
11
|
+
*
|
|
12
|
+
* ```jsonc
|
|
13
|
+
* {
|
|
14
|
+
* "kind": "appilot.app-manifest",
|
|
15
|
+
* "formatVersion": "1.0",
|
|
16
|
+
* "app": { "name": "Acme Booking" },
|
|
17
|
+
* "domains": [{ "domain": "app.acme.com" }],
|
|
18
|
+
* "widgetKeys": [{ "name": "production" }],
|
|
19
|
+
* "config": { "kind": "appilot.config-bundle", ... }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Plan before apply is structural, not advisory. `planManifest` returns a
|
|
24
|
+
* `planToken` derived from the manifest's own bytes, and `applyManifest`
|
|
25
|
+
* refuses without a matching one. A model that skips the plan step cannot
|
|
26
|
+
* produce the token by guessing, and a manifest edited between the two calls
|
|
27
|
+
* produces a different token, so the apply fails rather than committing
|
|
28
|
+
* something nobody previewed.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
export const MANIFEST_KIND = 'appilot.app-manifest';
|
|
32
|
+
export const MANIFEST_FORMAT_VERSION = '1.0';
|
|
33
|
+
/**
|
|
34
|
+
* Canonical JSON: object keys sorted at every level, so two manifests that
|
|
35
|
+
* differ only in key order produce the same token. Arrays keep their order,
|
|
36
|
+
* because in a manifest order is meaning (the first widget key is the one the
|
|
37
|
+
* snippets use).
|
|
38
|
+
*/
|
|
39
|
+
function canonicalize(value) {
|
|
40
|
+
if (Array.isArray(value))
|
|
41
|
+
return value.map(canonicalize);
|
|
42
|
+
if (value && typeof value === 'object') {
|
|
43
|
+
const source = value;
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const key of Object.keys(source).sort()) {
|
|
46
|
+
if (source[key] === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
out[key] = canonicalize(source[key]);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/** The token that binds an apply to the plan that previewed it. */
|
|
55
|
+
export function manifestDigest(manifest) {
|
|
56
|
+
return createHash('sha256').update(JSON.stringify(canonicalize(manifest))).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
export function parseManifest(input) {
|
|
59
|
+
const raw = typeof input === 'string' ? JSON.parse(input) : input;
|
|
60
|
+
const kind = raw.kind;
|
|
61
|
+
if (kind !== undefined && kind !== MANIFEST_KIND) {
|
|
62
|
+
throw new Error(`Not an Appilot app manifest (kind: ${String(kind)}).`);
|
|
63
|
+
}
|
|
64
|
+
const app = raw.app;
|
|
65
|
+
if (!app || typeof app.name !== 'string' || app.name.trim() === '') {
|
|
66
|
+
throw new Error('The manifest needs an app with a name.');
|
|
67
|
+
}
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
/** The provisioning request the manifest's first widget key describes. */
|
|
71
|
+
function provisionRequest(manifest, dryRun) {
|
|
72
|
+
return {
|
|
73
|
+
app: manifest.app,
|
|
74
|
+
domains: manifest.domains ?? [],
|
|
75
|
+
widgetKey: manifest.widgetKeys?.[0],
|
|
76
|
+
dryRun,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Preview everything the manifest would change. Writes nothing.
|
|
81
|
+
*
|
|
82
|
+
* Both halves run in dry-run: provisioning reports created/reused/updated per
|
|
83
|
+
* entity, and the config import returns its per-entity diff plus the health
|
|
84
|
+
* findings over the PROSPECTIVE post-apply state.
|
|
85
|
+
*/
|
|
86
|
+
export async function planManifest(client, manifest, resolveAppId) {
|
|
87
|
+
const notes = [];
|
|
88
|
+
const provisioning = await client.provisionApp(provisionRequest(manifest, true));
|
|
89
|
+
let config;
|
|
90
|
+
if (manifest.config) {
|
|
91
|
+
// The config half needs a real app id. On a first run the app does not
|
|
92
|
+
// exist yet, so there is nothing to diff against and the config lands on
|
|
93
|
+
// the apply pass instead. Say so rather than reporting an empty diff.
|
|
94
|
+
const appId = provisioning.app.id ?? resolveAppId(undefined);
|
|
95
|
+
if (appId == null) {
|
|
96
|
+
notes.push('The app does not exist yet, so the config bundle could not be diffed. It will be imported on apply, and the health gate still runs there.');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const diff = (await client.importConfig(appId, {
|
|
100
|
+
mode: 'merge',
|
|
101
|
+
dryRun: true,
|
|
102
|
+
bundle: manifest.config,
|
|
103
|
+
}));
|
|
104
|
+
config = { diff, expectedCurrentHash: diff?.currentHash };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { planToken: manifestDigest(manifest), provisioning, config, notes };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Apply a previously planned manifest.
|
|
111
|
+
*
|
|
112
|
+
* `planToken` must match the manifest being applied. This is the structural
|
|
113
|
+
* version of "always dry-run first": the guidance cannot be skipped, because
|
|
114
|
+
* the token is unobtainable without the plan call and changes with the
|
|
115
|
+
* manifest.
|
|
116
|
+
*/
|
|
117
|
+
export async function applyManifest(client, manifest, options) {
|
|
118
|
+
const expected = manifestDigest(manifest);
|
|
119
|
+
if (options.planToken !== expected) {
|
|
120
|
+
throw new Error('planToken does not match this manifest. Run plan_manifest on the exact manifest you intend to apply, then pass the planToken it returns. A mismatch means the manifest changed after it was previewed.');
|
|
121
|
+
}
|
|
122
|
+
const notes = [];
|
|
123
|
+
const provisioning = await client.provisionApp(provisionRequest(manifest, false));
|
|
124
|
+
let config;
|
|
125
|
+
if (manifest.config) {
|
|
126
|
+
const appId = provisioning.app.id;
|
|
127
|
+
if (appId == null) {
|
|
128
|
+
throw new Error('Provisioning returned no app id, so the config bundle cannot be imported.');
|
|
129
|
+
}
|
|
130
|
+
config = await client.importConfig(appId, {
|
|
131
|
+
mode: options.mode ?? 'merge',
|
|
132
|
+
dryRun: false,
|
|
133
|
+
bundle: manifest.config,
|
|
134
|
+
expectedCurrentHash: options.expectedCurrentHash,
|
|
135
|
+
allowUnhealthy: options.allowUnhealthy,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (provisioning.widgetKey?.rawSecret) {
|
|
139
|
+
notes.push('A widget key and secret were minted and are shown exactly once. Store the secret in the host backend only; it must never reach a browser.');
|
|
140
|
+
}
|
|
141
|
+
for (const domain of provisioning.domains) {
|
|
142
|
+
if (domain.dns_record_name) {
|
|
143
|
+
notes.push(`${domain.domain} is unverified. Publish TXT ${domain.dns_record_name} = ${domain.dns_record_value}, then verify it.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { provisioning, config, notes };
|
|
147
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool result may carry, per transport.
|
|
3
|
+
*
|
|
4
|
+
* The tool surface is identical across stdio and the remote HTTP service by
|
|
5
|
+
* design: one factory, one registration, both transports. What differs is where
|
|
6
|
+
* a result lands. Over stdio it stays on the operator's own machine. Over the
|
|
7
|
+
* remote transport it lands in a third party's conversation (ChatGPT,
|
|
8
|
+
* claude.ai), where it is stored, summarized, and outside the operator's
|
|
9
|
+
* control.
|
|
10
|
+
*
|
|
11
|
+
* Only one value in the whole surface is affected: the widget SECRET minted
|
|
12
|
+
* during provisioning. The widget KEY beside it is a publishable identifier
|
|
13
|
+
* that is meant to sit in public HTML, so it travels either way.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SECRET_WITHHELD_NOTICE = "The widget secret is not returned over the remote transport, because a tool result here is stored in this conversation. Read it once from the Backoffice widget-keys page, or run the Appilot MCP server locally over stdio.";
|
|
16
|
+
interface ProvisionLike {
|
|
17
|
+
widgetKey?: {
|
|
18
|
+
rawSecret?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Strip the minted widget secret from a provisioning result when the result is
|
|
23
|
+
* about to cross the remote transport, replacing it with a note saying where to
|
|
24
|
+
* get it instead.
|
|
25
|
+
*
|
|
26
|
+
* Returns the input unchanged over stdio, and unchanged when no secret was
|
|
27
|
+
* minted (a converging run never mints one).
|
|
28
|
+
*/
|
|
29
|
+
export declare function redactForTransport<T extends ProvisionLike>(result: T, transport: 'stdio' | 'http' | undefined): T;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool result may carry, per transport.
|
|
3
|
+
*
|
|
4
|
+
* The tool surface is identical across stdio and the remote HTTP service by
|
|
5
|
+
* design: one factory, one registration, both transports. What differs is where
|
|
6
|
+
* a result lands. Over stdio it stays on the operator's own machine. Over the
|
|
7
|
+
* remote transport it lands in a third party's conversation (ChatGPT,
|
|
8
|
+
* claude.ai), where it is stored, summarized, and outside the operator's
|
|
9
|
+
* control.
|
|
10
|
+
*
|
|
11
|
+
* Only one value in the whole surface is affected: the widget SECRET minted
|
|
12
|
+
* during provisioning. The widget KEY beside it is a publishable identifier
|
|
13
|
+
* that is meant to sit in public HTML, so it travels either way.
|
|
14
|
+
*/
|
|
15
|
+
export const SECRET_WITHHELD_NOTICE = 'The widget secret is not returned over the remote transport, because a tool result here is stored in this conversation. Read it once from the Backoffice widget-keys page, or run the Appilot MCP server locally over stdio.';
|
|
16
|
+
/**
|
|
17
|
+
* Strip the minted widget secret from a provisioning result when the result is
|
|
18
|
+
* about to cross the remote transport, replacing it with a note saying where to
|
|
19
|
+
* get it instead.
|
|
20
|
+
*
|
|
21
|
+
* Returns the input unchanged over stdio, and unchanged when no secret was
|
|
22
|
+
* minted (a converging run never mints one).
|
|
23
|
+
*/
|
|
24
|
+
export function redactForTransport(result, transport) {
|
|
25
|
+
if (transport !== 'http')
|
|
26
|
+
return result;
|
|
27
|
+
if (!result.widgetKey?.rawSecret)
|
|
28
|
+
return result;
|
|
29
|
+
const widgetKey = { ...result.widgetKey };
|
|
30
|
+
delete widgetKey.rawSecret;
|
|
31
|
+
widgetKey.secretWithheld = SECRET_WITHHELD_NOTICE;
|
|
32
|
+
return { ...result, widgetKey };
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent screen of the remote MCP service.
|
|
3
|
+
*
|
|
4
|
+
* This is the one page a person sees when connecting ChatGPT or Claude to their
|
|
5
|
+
* Appilot instance. It asks for a service token rather than an Appilot password
|
|
6
|
+
* on purpose: the connector needs a long-lived, scoped, revocable credential,
|
|
7
|
+
* which is exactly what a service token is and exactly what a password is not.
|
|
8
|
+
* A password would also make this host a credential-collection surface for the
|
|
9
|
+
* whole account, and it can grant no less than everything.
|
|
10
|
+
*
|
|
11
|
+
* Plain server-rendered HTML with no external assets, so it renders inside the
|
|
12
|
+
* in-app browsers ChatGPT and Claude use for the OAuth hop.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConsentPageOptions {
|
|
15
|
+
/** Sealed authorization request, round-tripped through the form. */
|
|
16
|
+
request: string;
|
|
17
|
+
/** Where the form posts. */
|
|
18
|
+
action: string;
|
|
19
|
+
/** Display name of the MCP client asking for access. */
|
|
20
|
+
clientName: string;
|
|
21
|
+
/** The Appilot backend this deployment serves, shown so the person can check it. */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** Scopes the client asked for. */
|
|
24
|
+
scopes: string[];
|
|
25
|
+
/** Set after a failed attempt so the person can correct it in place. */
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function renderConsentPage(opts: ConsentPageOptions): string;
|
|
29
|
+
export declare function renderErrorPage(title: string, detail: string): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent screen of the remote MCP service.
|
|
3
|
+
*
|
|
4
|
+
* This is the one page a person sees when connecting ChatGPT or Claude to their
|
|
5
|
+
* Appilot instance. It asks for a service token rather than an Appilot password
|
|
6
|
+
* on purpose: the connector needs a long-lived, scoped, revocable credential,
|
|
7
|
+
* which is exactly what a service token is and exactly what a password is not.
|
|
8
|
+
* A password would also make this host a credential-collection surface for the
|
|
9
|
+
* whole account, and it can grant no less than everything.
|
|
10
|
+
*
|
|
11
|
+
* Plain server-rendered HTML with no external assets, so it renders inside the
|
|
12
|
+
* in-app browsers ChatGPT and Claude use for the OAuth hop.
|
|
13
|
+
*/
|
|
14
|
+
const STYLE = `
|
|
15
|
+
:root { color-scheme: light dark; }
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
body { margin: 0; padding: 2rem 1rem; font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
18
|
+
background: #f6f7f9; color: #14161a; display: flex; justify-content: center; }
|
|
19
|
+
main { width: 100%; max-width: 30rem; background: #fff; border: 1px solid #e3e6ea; border-radius: 12px; padding: 1.75rem; }
|
|
20
|
+
h1 { font-size: 1.25rem; margin: 0 0 .35rem; }
|
|
21
|
+
p { margin: 0 0 1rem; color: #52585f; }
|
|
22
|
+
label { display: block; font-weight: 600; margin: 1rem 0 .35rem; }
|
|
23
|
+
input[type=text], input[type=password], input[type=number] { width: 100%; padding: .6rem .7rem; font: inherit;
|
|
24
|
+
border: 1px solid #c8cdd3; border-radius: 8px; background: #fff; color: inherit; }
|
|
25
|
+
input:focus-visible { outline: 2px solid #2f6feb; outline-offset: 1px; }
|
|
26
|
+
small { display: block; color: #6b7280; margin-top: .3rem; }
|
|
27
|
+
.scopes { list-style: none; padding: 0; margin: .5rem 0 0; }
|
|
28
|
+
.scopes li { padding: .35rem 0; border-top: 1px solid #eef0f3; }
|
|
29
|
+
.client { background: #f2f4f7; border-radius: 8px; padding: .75rem .9rem; margin-bottom: 1rem; }
|
|
30
|
+
.actions { display: flex; gap: .6rem; margin-top: 1.5rem; }
|
|
31
|
+
button { flex: 1; padding: .65rem 1rem; font: inherit; font-weight: 600; border-radius: 8px; cursor: pointer; border: 1px solid transparent; }
|
|
32
|
+
button.approve { background: #14161a; color: #fff; }
|
|
33
|
+
button.deny { background: #fff; border-color: #c8cdd3; color: #14161a; }
|
|
34
|
+
.error { background: #fdecec; border: 1px solid #f3bcbc; color: #8a1c1c; border-radius: 8px; padding: .7rem .9rem; margin-bottom: 1rem; }
|
|
35
|
+
@media (prefers-color-scheme: dark) {
|
|
36
|
+
body { background: #0f1115; color: #e8eaed; }
|
|
37
|
+
main { background: #171a1f; border-color: #2a2f36; }
|
|
38
|
+
p { color: #a4abb5; }
|
|
39
|
+
input[type=text], input[type=password], input[type=number] { background: #0f1115; border-color: #363c45; }
|
|
40
|
+
.client { background: #12151a; }
|
|
41
|
+
.scopes li { border-top-color: #2a2f36; }
|
|
42
|
+
button.approve { background: #e8eaed; color: #14161a; }
|
|
43
|
+
button.deny { background: #171a1f; border-color: #363c45; color: #e8eaed; }
|
|
44
|
+
.error { background: #2a1416; border-color: #5c2226; color: #f4b8bb; }
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
function escapeHtml(value) {
|
|
48
|
+
return value
|
|
49
|
+
.replace(/&/g, '&')
|
|
50
|
+
.replace(/</g, '<')
|
|
51
|
+
.replace(/>/g, '>')
|
|
52
|
+
.replace(/"/g, '"')
|
|
53
|
+
.replace(/'/g, ''');
|
|
54
|
+
}
|
|
55
|
+
function page(title, body) {
|
|
56
|
+
return `<!doctype html>
|
|
57
|
+
<html lang="en"><head>
|
|
58
|
+
<meta charset="utf-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
+
<title>${escapeHtml(title)}</title>
|
|
61
|
+
<style>${STYLE}</style>
|
|
62
|
+
</head><body><main>${body}</main></body></html>`;
|
|
63
|
+
}
|
|
64
|
+
const SCOPE_COPY = {
|
|
65
|
+
'config:read': 'Read this app\'s configuration: views, controls, forms, action plans, knowledge.',
|
|
66
|
+
'config:write': 'Change that configuration, including importing a whole bundle.',
|
|
67
|
+
};
|
|
68
|
+
export function renderConsentPage(opts) {
|
|
69
|
+
const scopes = opts.scopes.length ? opts.scopes : ['config:read'];
|
|
70
|
+
const items = scopes
|
|
71
|
+
.map(s => `<li><strong>${escapeHtml(s)}</strong><br><small>${escapeHtml(SCOPE_COPY[s] ?? 'Unknown scope.')}</small></li>`)
|
|
72
|
+
.join('');
|
|
73
|
+
return page('Connect to Appilot', `
|
|
74
|
+
<h1>Connect to Appilot</h1>
|
|
75
|
+
<p>Give <strong>${escapeHtml(opts.clientName)}</strong> access to your Appilot configuration.</p>
|
|
76
|
+
${opts.error ? `<div class="error">${escapeHtml(opts.error)}</div>` : ''}
|
|
77
|
+
<div class="client">
|
|
78
|
+
<small>Instance</small>
|
|
79
|
+
<div>${escapeHtml(opts.baseUrl)}</div>
|
|
80
|
+
</div>
|
|
81
|
+
<form method="post" action="${escapeHtml(opts.action)}">
|
|
82
|
+
<input type="hidden" name="request" value="${escapeHtml(opts.request)}">
|
|
83
|
+
<label for="pat">Service token</label>
|
|
84
|
+
<input id="pat" name="pat" type="password" autocomplete="off" spellcheck="false" required
|
|
85
|
+
placeholder="appilot_pat_..." aria-describedby="pat-help">
|
|
86
|
+
<small id="pat-help">Create one in the Backoffice under Settings, Service Tokens. Revoking it there cuts this connection off.</small>
|
|
87
|
+
<label for="app_id">Default app ID <span style="font-weight:400">(optional)</span></label>
|
|
88
|
+
<input id="app_id" name="app_id" type="number" min="1" inputmode="numeric" aria-describedby="app-help">
|
|
89
|
+
<small id="app-help">Used when a tool call does not name an app. An app-scoped token supplies its own.</small>
|
|
90
|
+
<ul class="scopes">${items}</ul>
|
|
91
|
+
<div class="actions">
|
|
92
|
+
<button class="deny" type="submit" name="action" value="deny">Cancel</button>
|
|
93
|
+
<button class="approve" type="submit" name="action" value="approve">Connect</button>
|
|
94
|
+
</div>
|
|
95
|
+
</form>`);
|
|
96
|
+
}
|
|
97
|
+
export function renderErrorPage(title, detail) {
|
|
98
|
+
return page(title, `<h1>${escapeHtml(title)}</h1><div class="error">${escapeHtml(detail)}</div>`);
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
|
|
3
|
+
* authorization server that fronts it.
|
|
4
|
+
*
|
|
5
|
+
* Stateless by construction. Each request builds its own MCP server bound to the
|
|
6
|
+
* caller's own service token, which arrives sealed inside the bearer token and
|
|
7
|
+
* never crosses between callers. Nothing is retained between requests, so a
|
|
8
|
+
* second instance behaves exactly like the first and a cold start loses nothing.
|
|
9
|
+
*
|
|
10
|
+
* Deployment: docs/architecture/appilot-mcp.md, section "Remote deployment".
|
|
11
|
+
*/
|
|
12
|
+
import { type Express } from 'express';
|
|
13
|
+
import type { RemoteConfig } from '../config.js';
|
|
14
|
+
import { AppilotOAuthProvider } from './oauth.js';
|
|
15
|
+
export interface RemoteAppOptions {
|
|
16
|
+
/** Swappable in tests so no real instance is contacted. */
|
|
17
|
+
provider?: AppilotOAuthProvider;
|
|
18
|
+
}
|
|
19
|
+
export declare function createRemoteApp(config: RemoteConfig, options?: RemoteAppOptions): Express;
|
|
20
|
+
export declare function startRemote(config: RemoteConfig): Promise<void>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
|
|
3
|
+
* authorization server that fronts it.
|
|
4
|
+
*
|
|
5
|
+
* Stateless by construction. Each request builds its own MCP server bound to the
|
|
6
|
+
* caller's own service token, which arrives sealed inside the bearer token and
|
|
7
|
+
* never crosses between callers. Nothing is retained between requests, so a
|
|
8
|
+
* second instance behaves exactly like the first and a cold start loses nothing.
|
|
9
|
+
*
|
|
10
|
+
* Deployment: docs/architecture/appilot-mcp.md, section "Remote deployment".
|
|
11
|
+
*/
|
|
12
|
+
import express from 'express';
|
|
13
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
+
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
15
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
16
|
+
import { createAppilotServer } from '../server.js';
|
|
17
|
+
import { AppilotOAuthProvider, SUPPORTED_SCOPES } from './oauth.js';
|
|
18
|
+
/** Where the docs root lands a person who follows the OAuth metadata. */
|
|
19
|
+
const CONFIGURE_WITH_AI_PATH = '/docs/developers/configure-with-ai/overview';
|
|
20
|
+
/** Body cap for a JSON-RPC request. A ConfigBundle import is the large one. */
|
|
21
|
+
const MAX_BODY = '32mb';
|
|
22
|
+
export function createRemoteApp(config, options = {}) {
|
|
23
|
+
const provider = options.provider ??
|
|
24
|
+
new AppilotOAuthProvider({
|
|
25
|
+
publicUrl: config.publicUrl,
|
|
26
|
+
baseUrl: config.baseUrl,
|
|
27
|
+
secret: config.secret,
|
|
28
|
+
});
|
|
29
|
+
const mcpUrl = new URL('/mcp', config.publicUrl);
|
|
30
|
+
const app = express();
|
|
31
|
+
app.disable('x-powered-by');
|
|
32
|
+
// Liveness for the platform. Deliberately says nothing a caller could not
|
|
33
|
+
// already learn from the instance's own public capabilities endpoint.
|
|
34
|
+
// Two paths for one probe. Cloud Run's frontend swallows /healthz before it
|
|
35
|
+
// reaches the container (it answers with Google's own 404 page), so a
|
|
36
|
+
// deployment there can only be probed at /health. /healthz stays for
|
|
37
|
+
// on-premise and Kubernetes, where it is the conventional name.
|
|
38
|
+
app.get(['/health', '/healthz'], (_req, res) => {
|
|
39
|
+
res.json({ status: 'ok', transport: 'http', instance: config.baseUrl });
|
|
40
|
+
});
|
|
41
|
+
// /.well-known/oauth-authorization-server, /authorize, /token, /register.
|
|
42
|
+
// Must be mounted at the application root.
|
|
43
|
+
app.use(mcpAuthRouter({
|
|
44
|
+
provider,
|
|
45
|
+
issuerUrl: config.publicUrl,
|
|
46
|
+
baseUrl: config.publicUrl,
|
|
47
|
+
resourceServerUrl: mcpUrl,
|
|
48
|
+
resourceName: 'Appilot configuration',
|
|
49
|
+
scopesSupported: [...SUPPORTED_SCOPES],
|
|
50
|
+
serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
|
|
51
|
+
}));
|
|
52
|
+
// The consent screen posts here. Form-encoded, same-origin, no JSON.
|
|
53
|
+
app.post('/consent', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const outcome = await provider.handleConsent(req.body);
|
|
56
|
+
if ('redirect' in outcome) {
|
|
57
|
+
res.redirect(302, outcome.redirect);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.status(outcome.status).set('Content-Type', 'text/html; charset=utf-8').send(outcome.html);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// The consent screen is person-facing, so a failure renders as a page.
|
|
64
|
+
res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(`<!doctype html><meta charset="utf-8"><title>Connection failed</title><p>The connection could not be completed: ${err instanceof Error ? err.message.replace(/[<>&]/g, '') : 'unknown error'}</p>`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const requireAuth = requireBearerAuth({
|
|
68
|
+
verifier: provider,
|
|
69
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
|
70
|
+
});
|
|
71
|
+
app.post('/mcp', requireAuth, express.json({ limit: MAX_BODY }), async (req, res) => {
|
|
72
|
+
const auth = req.auth;
|
|
73
|
+
const extra = (auth?.extra ?? {});
|
|
74
|
+
// One server and one transport per request. The connection profile is the
|
|
75
|
+
// caller's own: the deployment's fixed instance URL, their service token.
|
|
76
|
+
const server = createAppilotServer({
|
|
77
|
+
baseUrl: config.baseUrl,
|
|
78
|
+
token: extra.pat,
|
|
79
|
+
defaultAppId: extra.appId,
|
|
80
|
+
transport: 'http',
|
|
81
|
+
});
|
|
82
|
+
const transport = new StreamableHTTPServerTransport({
|
|
83
|
+
sessionIdGenerator: undefined,
|
|
84
|
+
enableDnsRebindingProtection: true,
|
|
85
|
+
allowedHosts: config.allowedHosts,
|
|
86
|
+
});
|
|
87
|
+
res.on('close', () => {
|
|
88
|
+
void transport.close();
|
|
89
|
+
void server.close();
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
await server.connect(transport);
|
|
93
|
+
await transport.handleRequest(req, res, req.body);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (!res.headersSent) {
|
|
97
|
+
res.status(500).json({
|
|
98
|
+
jsonrpc: '2.0',
|
|
99
|
+
error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' },
|
|
100
|
+
id: null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// Stateless: there is no stream to resume and no session to delete.
|
|
106
|
+
const methodNotAllowed = (_req, res) => {
|
|
107
|
+
res.status(405).set('Allow', 'POST').json({
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
error: { code: -32000, message: 'This endpoint is stateless: use POST.' },
|
|
110
|
+
id: null,
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
app.get('/mcp', methodNotAllowed);
|
|
114
|
+
app.delete('/mcp', methodNotAllowed);
|
|
115
|
+
return app;
|
|
116
|
+
}
|
|
117
|
+
export function startRemote(config) {
|
|
118
|
+
const app = createRemoteApp(config);
|
|
119
|
+
return new Promise(resolve => {
|
|
120
|
+
app.listen(config.port, () => {
|
|
121
|
+
process.stderr.write(`[appilot-mcp] http transport on :${config.port} · public=${config.publicUrl.origin} · instance=${config.baseUrl}\n`);
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|