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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +43 -0
  2. package/.codex-plugin/plugin.json +37 -0
  3. package/.mcp.json +19 -0
  4. package/README.md +268 -6
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/client.d.ts +140 -0
  7. package/dist/client.js +252 -0
  8. package/dist/config.d.ts +64 -0
  9. package/dist/config.js +78 -0
  10. package/dist/contract/bundleSnapshot.d.ts +12 -0
  11. package/dist/contract/bundleSnapshot.js +65 -0
  12. package/dist/contract/healthContract.d.ts +19 -0
  13. package/dist/contract/healthContract.js +297 -0
  14. package/dist/contract/index.d.ts +3 -0
  15. package/dist/contract/index.js +3 -0
  16. package/dist/contract/types.d.ts +86 -0
  17. package/dist/contract/types.js +9 -0
  18. package/dist/index.bundle.js +70059 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.js +50 -0
  21. package/dist/manifest.d.ts +93 -0
  22. package/dist/manifest.js +147 -0
  23. package/dist/redaction.d.ts +30 -0
  24. package/dist/redaction.js +33 -0
  25. package/dist/remote/consent.d.ts +29 -0
  26. package/dist/remote/consent.js +99 -0
  27. package/dist/remote/httpServer.d.ts +20 -0
  28. package/dist/remote/httpServer.js +125 -0
  29. package/dist/remote/oauth.d.ts +74 -0
  30. package/dist/remote/oauth.js +288 -0
  31. package/dist/remote/tokens.d.ts +28 -0
  32. package/dist/remote/tokens.js +50 -0
  33. package/dist/scaffold.d.ts +37 -0
  34. package/dist/scaffold.js +203 -0
  35. package/dist/server.d.ts +15 -0
  36. package/dist/server.js +358 -0
  37. package/dist/soak.d.ts +32 -0
  38. package/dist/soak.js +51 -0
  39. package/dist/verify.d.ts +40 -0
  40. package/dist/verify.js +149 -0
  41. package/mcpb/manifest.json +67 -0
  42. package/package.json +70 -16
  43. package/skills/app-configurator/SKILL.md +198 -0
  44. package/skills/app-configurator/agents/openai.yaml +13 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * OAuth 2.1 authorization server for the remote MCP service.
3
+ *
4
+ * ChatGPT and claude.ai offer exactly two ways to reach a remote MCP server:
5
+ * no authentication, or OAuth. Neither has a field for pasting an API key. So a
6
+ * remote Appilot MCP endpoint that anyone can safely use has to speak OAuth,
7
+ * with PKCE and dynamic client registration, and that is what this file is.
8
+ *
9
+ * What it does NOT do is own identity. Appilot already has users, orgs, and a
10
+ * scoped, revocable credential for exactly this purpose: the service token. The
11
+ * authorization step therefore collects a service token on the consent screen,
12
+ * verifies it against the instance, and seals it inside the OAuth tokens it
13
+ * issues. The service holds no user database, no client registry, and no
14
+ * credential of its own. See remote/tokens.ts for why everything is sealed
15
+ * rather than stored.
16
+ *
17
+ * Spec: docs/architecture/appilot-mcp.md, section "Remote deployment".
18
+ */
19
+ import type { Response } from 'express';
20
+ import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
21
+ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
22
+ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
23
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
24
+ /** Scopes this authorization server can grant, mirroring the service-token scopes. */
25
+ export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write"];
26
+ /** What the instance says a credential reaches. Absent on instances without /config/whoami. */
27
+ export interface TokenIdentity {
28
+ organizationId: number | null;
29
+ appId: number | null;
30
+ scopes: string[];
31
+ }
32
+ export interface AppilotOAuthOptions {
33
+ /** Public origin of this service, used to build the consent form action. */
34
+ publicUrl: URL;
35
+ /** The Appilot backend this deployment serves. */
36
+ baseUrl: string;
37
+ /** Secret behind every sealed artifact. */
38
+ secret: string;
39
+ /** Swappable for tests; defaults to a real call to GET /config/whoami. */
40
+ verifyServiceToken?: (pat: string) => Promise<TokenIdentity | null>;
41
+ }
42
+ export declare class AppilotOAuthProvider implements OAuthServerProvider {
43
+ private readonly options;
44
+ private readonly sealer;
45
+ private readonly consentPath;
46
+ private readonly verifyServiceToken;
47
+ constructor(options: AppilotOAuthOptions);
48
+ /**
49
+ * Client registrations are sealed into the client_id itself, so dynamic
50
+ * registration works without a registry. A client that presents an id we did
51
+ * not issue fails to open it, which is the same outcome as an unknown id.
52
+ */
53
+ get clientsStore(): OAuthRegisteredClientsStore;
54
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
55
+ /** The authorization request travels through the consent form, so it is sealed too. */
56
+ private sealAuthRequest;
57
+ /**
58
+ * Handle the consent form post: verify the pasted service token, then either
59
+ * redirect back with an authorization code or re-render with the reason it
60
+ * failed. Returns the HTML to render, or the redirect to follow.
61
+ */
62
+ handleConsent(form: Record<string, unknown>): Promise<{
63
+ redirect: string;
64
+ } | {
65
+ html: string;
66
+ status: number;
67
+ }>;
68
+ private openCode;
69
+ challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
70
+ exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string): Promise<OAuthTokens>;
71
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
72
+ private issue;
73
+ verifyAccessToken(token: string): Promise<AuthInfo>;
74
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * OAuth 2.1 authorization server for the remote MCP service.
3
+ *
4
+ * ChatGPT and claude.ai offer exactly two ways to reach a remote MCP server:
5
+ * no authentication, or OAuth. Neither has a field for pasting an API key. So a
6
+ * remote Appilot MCP endpoint that anyone can safely use has to speak OAuth,
7
+ * with PKCE and dynamic client registration, and that is what this file is.
8
+ *
9
+ * What it does NOT do is own identity. Appilot already has users, orgs, and a
10
+ * scoped, revocable credential for exactly this purpose: the service token. The
11
+ * authorization step therefore collects a service token on the consent screen,
12
+ * verifies it against the instance, and seals it inside the OAuth tokens it
13
+ * issues. The service holds no user database, no client registry, and no
14
+ * credential of its own. See remote/tokens.ts for why everything is sealed
15
+ * rather than stored.
16
+ *
17
+ * Spec: docs/architecture/appilot-mcp.md, section "Remote deployment".
18
+ */
19
+ import { InvalidGrantError, InvalidTokenError, InvalidClientError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
20
+ import { TokenSealer, SealError } from './tokens.js';
21
+ import { renderConsentPage, renderErrorPage } from './consent.js';
22
+ /** Scopes this authorization server can grant, mirroring the service-token scopes. */
23
+ export const SUPPORTED_SCOPES = ['config:read', 'config:write'];
24
+ const AUTH_REQUEST_TTL = 10 * 60;
25
+ const CODE_TTL = 60;
26
+ const ACCESS_TTL = 60 * 60;
27
+ const REFRESH_TTL = 90 * 24 * 60 * 60;
28
+ const SERVICE_TOKEN_PREFIX = 'appilot_pat_';
29
+ /**
30
+ * Ask the instance what a pasted service token actually reaches. Returns null
31
+ * when the instance rejects it. Returns an empty identity when the instance
32
+ * predates /config/whoami, which is not a rejection: older instances simply
33
+ * cannot answer, and the first tool call will surface a bad token instead.
34
+ */
35
+ async function defaultVerifyServiceToken(baseUrl, pat) {
36
+ let res;
37
+ try {
38
+ res = await fetch(`${baseUrl}/config/whoami`, {
39
+ headers: { Authorization: `Bearer ${pat}`, Accept: 'application/json' },
40
+ });
41
+ }
42
+ catch (err) {
43
+ throw new ServerError(`Could not reach the Appilot instance at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
44
+ }
45
+ if (res.status === 401 || res.status === 403)
46
+ return null;
47
+ if (res.status === 404) {
48
+ // Instance predates the self-check. Accept the token on shape alone.
49
+ return { organizationId: null, appId: null, scopes: [...SUPPORTED_SCOPES] };
50
+ }
51
+ if (!res.ok) {
52
+ throw new ServerError(`The Appilot instance answered ${res.status} while verifying the service token.`);
53
+ }
54
+ const body = (await res.json());
55
+ return {
56
+ organizationId: body.organizationId ?? null,
57
+ appId: body.appId ?? null,
58
+ scopes: Array.isArray(body.scopes) && body.scopes.length ? body.scopes : ['config:read'],
59
+ };
60
+ }
61
+ export class AppilotOAuthProvider {
62
+ options;
63
+ sealer;
64
+ consentPath;
65
+ verifyServiceToken;
66
+ constructor(options) {
67
+ this.options = options;
68
+ this.sealer = new TokenSealer(options.secret);
69
+ this.consentPath = new URL('/consent', options.publicUrl).toString();
70
+ this.verifyServiceToken =
71
+ options.verifyServiceToken ?? (pat => defaultVerifyServiceToken(options.baseUrl, pat));
72
+ }
73
+ /**
74
+ * Client registrations are sealed into the client_id itself, so dynamic
75
+ * registration works without a registry. A client that presents an id we did
76
+ * not issue fails to open it, which is the same outcome as an unknown id.
77
+ */
78
+ get clientsStore() {
79
+ return {
80
+ registerClient: async (client) => {
81
+ // Every registration is a public client. MCP mandates PKCE, which is
82
+ // the proof of possession here, and a shared secret would have to be
83
+ // carried inside the client id we hand back: a secret in the same
84
+ // string as the identifier it is supposed to protect. Drop the one
85
+ // the registration handler generated rather than seal it.
86
+ const { client_secret: _secret, client_secret_expires_at: _expires, ...meta } = client;
87
+ const publicMeta = { ...meta, token_endpoint_auth_method: 'none' };
88
+ const client_id = await this.sealer.seal('client', { meta: publicMeta });
89
+ return {
90
+ ...publicMeta,
91
+ client_id,
92
+ client_id_issued_at: Math.floor(Date.now() / 1000),
93
+ };
94
+ },
95
+ getClient: async (client_id) => {
96
+ try {
97
+ const sealed = await this.sealer.open('client', client_id);
98
+ return {
99
+ ...sealed.meta,
100
+ client_id,
101
+ client_id_issued_at: sealed.iat,
102
+ token_endpoint_auth_method: 'none',
103
+ };
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ },
109
+ };
110
+ }
111
+ async authorize(client, params, res) {
112
+ const requested = (params.scopes?.length ? params.scopes : ['config:read']).filter(s => SUPPORTED_SCOPES.includes(s));
113
+ const sealedRequest = await this.sealAuthRequest({
114
+ client_id: client.client_id,
115
+ redirect_uri: params.redirectUri,
116
+ code_challenge: params.codeChallenge,
117
+ state: params.state,
118
+ scopes: requested.length ? requested : ['config:read'],
119
+ resource: params.resource?.toString(),
120
+ });
121
+ res.set('Content-Type', 'text/html; charset=utf-8').send(renderConsentPage({
122
+ request: sealedRequest,
123
+ action: this.consentPath,
124
+ clientName: client.client_name || 'an MCP client',
125
+ baseUrl: this.options.baseUrl,
126
+ scopes: requested.length ? requested : ['config:read'],
127
+ }));
128
+ }
129
+ /** The authorization request travels through the consent form, so it is sealed too. */
130
+ sealAuthRequest(request) {
131
+ return this.sealer.seal('code', { ...request, stage: 'request' }, AUTH_REQUEST_TTL);
132
+ }
133
+ /**
134
+ * Handle the consent form post: verify the pasted service token, then either
135
+ * redirect back with an authorization code or re-render with the reason it
136
+ * failed. Returns the HTML to render, or the redirect to follow.
137
+ */
138
+ async handleConsent(form) {
139
+ const rawRequest = typeof form.request === 'string' ? form.request : '';
140
+ let request;
141
+ try {
142
+ request = await this.sealer.open('code', rawRequest);
143
+ }
144
+ catch {
145
+ return {
146
+ status: 400,
147
+ html: renderErrorPage('This sign-in link expired', 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.'),
148
+ };
149
+ }
150
+ if (request.stage !== 'request') {
151
+ return { status: 400, html: renderErrorPage('Invalid request', 'That token is not a consent request.') };
152
+ }
153
+ const redirect = new URL(request.redirect_uri);
154
+ if (form.action === 'deny') {
155
+ redirect.searchParams.set('error', 'access_denied');
156
+ redirect.searchParams.set('error_description', 'The person declined the request.');
157
+ if (request.state)
158
+ redirect.searchParams.set('state', request.state);
159
+ return { redirect: redirect.toString() };
160
+ }
161
+ const pat = typeof form.pat === 'string' ? form.pat.trim() : '';
162
+ const reRender = (error) => ({
163
+ status: 400,
164
+ html: renderConsentPage({
165
+ request: rawRequest,
166
+ action: this.consentPath,
167
+ clientName: 'an MCP client',
168
+ baseUrl: this.options.baseUrl,
169
+ scopes: request.scopes,
170
+ error,
171
+ }),
172
+ });
173
+ if (!pat.startsWith(SERVICE_TOKEN_PREFIX)) {
174
+ return reRender(`That does not look like a service token. A service token starts with ${SERVICE_TOKEN_PREFIX} and is created in the Backoffice under Settings, Service Tokens.`);
175
+ }
176
+ let identity;
177
+ try {
178
+ identity = await this.verifyServiceToken(pat);
179
+ }
180
+ catch (err) {
181
+ return reRender(err instanceof Error ? err.message : String(err));
182
+ }
183
+ if (!identity) {
184
+ return reRender('The instance rejected that service token. It may have been revoked or have expired.');
185
+ }
186
+ // Never grant more than the token itself carries.
187
+ const granted = request.scopes.filter(s => identity.scopes.includes(s));
188
+ if (granted.length === 0) {
189
+ return reRender(`That token grants ${identity.scopes.join(', ')}, which does not cover the requested ${request.scopes.join(', ')}. Create a token with the needed scope.`);
190
+ }
191
+ // An app-scoped token dictates its own app; a form value cannot widen it.
192
+ const formAppId = Number(form.app_id);
193
+ const appId = identity.appId ?? (Number.isInteger(formAppId) && formAppId > 0 ? formAppId : undefined);
194
+ const code = await this.sealer.seal('code', {
195
+ ...request,
196
+ stage: 'code',
197
+ scopes: granted,
198
+ pat,
199
+ app_id: appId,
200
+ organization_id: identity.organizationId ?? undefined,
201
+ }, CODE_TTL);
202
+ redirect.searchParams.set('code', code);
203
+ if (request.state)
204
+ redirect.searchParams.set('state', request.state);
205
+ return { redirect: redirect.toString() };
206
+ }
207
+ async openCode(client, authorizationCode) {
208
+ let code;
209
+ try {
210
+ code = await this.sealer.open('code', authorizationCode);
211
+ }
212
+ catch (err) {
213
+ throw new InvalidGrantError(err instanceof SealError ? 'The authorization code is invalid or has expired.' : String(err));
214
+ }
215
+ if (code.stage !== 'code')
216
+ throw new InvalidGrantError('That token is not an authorization code.');
217
+ if (code.client_id !== client.client_id)
218
+ throw new InvalidClientError('The authorization code was issued to a different client.');
219
+ return code;
220
+ }
221
+ async challengeForAuthorizationCode(client, authorizationCode) {
222
+ return (await this.openCode(client, authorizationCode)).code_challenge;
223
+ }
224
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri) {
225
+ const code = await this.openCode(client, authorizationCode);
226
+ if (redirectUri !== undefined && redirectUri !== code.redirect_uri) {
227
+ throw new InvalidGrantError('redirect_uri does not match the one the code was issued for.');
228
+ }
229
+ return this.issue(code);
230
+ }
231
+ async exchangeRefreshToken(client, refreshToken, scopes) {
232
+ let sealed;
233
+ try {
234
+ sealed = await this.sealer.open('refresh', refreshToken);
235
+ }
236
+ catch {
237
+ throw new InvalidGrantError('The refresh token is invalid or has expired.');
238
+ }
239
+ if (sealed.client_id !== client.client_id) {
240
+ throw new InvalidClientError('The refresh token was issued to a different client.');
241
+ }
242
+ // A refresh may narrow the grant, never widen it.
243
+ const narrowed = scopes?.length ? sealed.scopes.filter(s => scopes.includes(s)) : sealed.scopes;
244
+ if (narrowed.length === 0)
245
+ throw new InvalidGrantError('The requested scopes are not covered by this grant.');
246
+ return this.issue({ ...sealed, scopes: narrowed });
247
+ }
248
+ async issue(grant) {
249
+ const payload = {
250
+ client_id: grant.client_id,
251
+ scopes: grant.scopes,
252
+ pat: grant.pat,
253
+ app_id: grant.app_id,
254
+ organization_id: grant.organization_id,
255
+ resource: grant.resource,
256
+ };
257
+ const [access_token, refresh_token] = await Promise.all([
258
+ this.sealer.seal('access', payload, ACCESS_TTL),
259
+ this.sealer.seal('refresh', payload, REFRESH_TTL),
260
+ ]);
261
+ return {
262
+ access_token,
263
+ token_type: 'Bearer',
264
+ expires_in: ACCESS_TTL,
265
+ scope: grant.scopes.join(' '),
266
+ refresh_token,
267
+ };
268
+ }
269
+ async verifyAccessToken(token) {
270
+ let sealed;
271
+ try {
272
+ sealed = await this.sealer.open('access', token);
273
+ }
274
+ catch {
275
+ throw new InvalidTokenError('The access token is invalid or has expired.');
276
+ }
277
+ return {
278
+ token,
279
+ clientId: sealed.client_id,
280
+ scopes: sealed.scopes,
281
+ expiresAt: sealed.exp,
282
+ resource: sealed.resource ? new URL(sealed.resource) : undefined,
283
+ // The service token never leaves the server: it is read here to build the
284
+ // per-request Appilot client and is not echoed in any response.
285
+ extra: { pat: sealed.pat, appId: sealed.app_id, organizationId: sealed.organization_id },
286
+ };
287
+ }
288
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Sealed tokens for the remote MCP service.
3
+ *
4
+ * Every artifact the OAuth authorization server hands out (client id,
5
+ * authorization code, access token, refresh token) is an encrypted JWT carrying
6
+ * its own state. That is a deliberate choice: it lets the service run with no
7
+ * database and no shared cache, so a second instance, a cold start, or an
8
+ * air-gapped copy behaves identically to the first. The cost is that a sealed
9
+ * artifact cannot be deleted server-side. Codes are short-lived and PKCE-bound,
10
+ * so replay needs the verifier; access tokens are short-lived; and the credential
11
+ * that actually reaches Appilot data, the service token sealed inside, is
12
+ * revoked where it was minted, in the Backoffice.
13
+ *
14
+ * The payload holds a live service token, so these are encrypted (JWE, A256GCM),
15
+ * never merely signed. A client sees an opaque string.
16
+ */
17
+ /** What a sealed artifact is for. Mixing them up is rejected by the audience check. */
18
+ export type SealKind = 'client' | 'code' | 'access' | 'refresh';
19
+ export declare class SealError extends Error {
20
+ }
21
+ export declare class TokenSealer {
22
+ private readonly key;
23
+ constructor(secret: string);
24
+ /** Seal a payload. `ttlSeconds` omitted means no expiry (client registrations). */
25
+ seal(kind: SealKind, payload: Record<string, unknown>, ttlSeconds?: number): Promise<string>;
26
+ /** Open a sealed artifact, or throw SealError. Expiry and audience are enforced. */
27
+ open<T extends Record<string, unknown>>(kind: SealKind, token: string): Promise<T>;
28
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Sealed tokens for the remote MCP service.
3
+ *
4
+ * Every artifact the OAuth authorization server hands out (client id,
5
+ * authorization code, access token, refresh token) is an encrypted JWT carrying
6
+ * its own state. That is a deliberate choice: it lets the service run with no
7
+ * database and no shared cache, so a second instance, a cold start, or an
8
+ * air-gapped copy behaves identically to the first. The cost is that a sealed
9
+ * artifact cannot be deleted server-side. Codes are short-lived and PKCE-bound,
10
+ * so replay needs the verifier; access tokens are short-lived; and the credential
11
+ * that actually reaches Appilot data, the service token sealed inside, is
12
+ * revoked where it was minted, in the Backoffice.
13
+ *
14
+ * The payload holds a live service token, so these are encrypted (JWE, A256GCM),
15
+ * never merely signed. A client sees an opaque string.
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import { EncryptJWT, jwtDecrypt } from 'jose';
19
+ const ISSUER = 'appilot-mcp';
20
+ export class SealError extends Error {
21
+ }
22
+ export class TokenSealer {
23
+ key;
24
+ constructor(secret) {
25
+ // A256GCM needs exactly 32 bytes; the operator's secret is a passphrase of
26
+ // arbitrary length, so derive rather than require a precise encoding.
27
+ this.key = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
28
+ }
29
+ /** Seal a payload. `ttlSeconds` omitted means no expiry (client registrations). */
30
+ async seal(kind, payload, ttlSeconds) {
31
+ let jwt = new EncryptJWT({ ...payload })
32
+ .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
33
+ .setIssuedAt()
34
+ .setIssuer(ISSUER)
35
+ .setAudience(kind);
36
+ if (ttlSeconds !== undefined)
37
+ jwt = jwt.setExpirationTime(`${ttlSeconds}s`);
38
+ return jwt.encrypt(this.key);
39
+ }
40
+ /** Open a sealed artifact, or throw SealError. Expiry and audience are enforced. */
41
+ async open(kind, token) {
42
+ try {
43
+ const { payload } = await jwtDecrypt(token, this.key, { issuer: ISSUER, audience: kind });
44
+ return payload;
45
+ }
46
+ catch (err) {
47
+ throw new SealError(`Sealed ${kind} is invalid, expired, or was issued under a different secret: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `scaffold_integration`: the source a host application needs, as text.
3
+ *
4
+ * The MCP server returns content and never touches the customer's filesystem.
5
+ * The coding agent calling it already knows where files go in that repository,
6
+ * has the user's approval to write them, and can reconcile them with what is
7
+ * there. A server that wrote files itself would be guessing at all three.
8
+ *
9
+ * The relay is the piece worth generating. It is the one part of the
10
+ * integration a developer must write in their own backend, it is
11
+ * security-critical, and getting it wrong is invisible until an auth edge case
12
+ * shows up in production.
13
+ */
14
+ export type Framework = 'next' | 'express' | 'fastify' | 'hono' | 'remix' | 'sveltekit';
15
+ export interface ScaffoldFile {
16
+ /** Suggested path, relative to the repository root. The agent may move it. */
17
+ path: string;
18
+ language: string;
19
+ contents: string;
20
+ }
21
+ export interface ScaffoldResult {
22
+ framework: Framework;
23
+ files: ScaffoldFile[];
24
+ install: string;
25
+ env: string;
26
+ notes: string[];
27
+ }
28
+ interface ScaffoldOptions {
29
+ framework: Framework;
30
+ widgetScriptUrl: string;
31
+ apiUrl?: string | null;
32
+ widgetKey?: string | null;
33
+ /** Namespace for externalId, so a host's ids never collide with another's. */
34
+ idNamespace?: string;
35
+ }
36
+ export declare function scaffoldIntegration(options: ScaffoldOptions): ScaffoldResult;
37
+ export {};
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `scaffold_integration`: the source a host application needs, as text.
3
+ *
4
+ * The MCP server returns content and never touches the customer's filesystem.
5
+ * The coding agent calling it already knows where files go in that repository,
6
+ * has the user's approval to write them, and can reconcile them with what is
7
+ * there. A server that wrote files itself would be guessing at all three.
8
+ *
9
+ * The relay is the piece worth generating. It is the one part of the
10
+ * integration a developer must write in their own backend, it is
11
+ * security-critical, and getting it wrong is invisible until an auth edge case
12
+ * shows up in production.
13
+ */
14
+ function relayFetchHandler(namespace) {
15
+ return `import { createWidgetTokenHandler } from 'appilot-server';
16
+
17
+ // resolveUser is the security boundary. Derive the user from a credential you
18
+ // trust (your session cookie, your own JWT). Never from the request body.
19
+ export const POST = createWidgetTokenHandler({
20
+ apiUrl: process.env.APPILOT_API_URL!,
21
+ widgetKey: process.env.APPILOT_WIDGET_KEY!,
22
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET!, // server-side only
23
+ resolveUser: async request => {
24
+ const session = await getSession(request); // your auth, unchanged
25
+ if (!session) return null; // 401 IDENTITY_REQUIRED
26
+ return {
27
+ externalId: \`${namespace}:\${session.userId}\`,
28
+ displayName: session.name,
29
+ };
30
+ },
31
+ });
32
+ `;
33
+ }
34
+ function relayNodeHandler(namespace, framework) {
35
+ const mount = framework === 'express'
36
+ ? `app.post('/api/widget/token', relay);`
37
+ : `fastify.post('/api/widget/token', (request, reply) => relay(request.raw, reply.raw));`;
38
+ return `import { createNodeWidgetTokenHandler } from 'appilot-server/node';
39
+
40
+ // resolveUser is the security boundary. Derive the user from a credential you
41
+ // trust (req.session, req.user). Never from the request body.
42
+ const relay = createNodeWidgetTokenHandler({
43
+ apiUrl: process.env.APPILOT_API_URL,
44
+ widgetKey: process.env.APPILOT_WIDGET_KEY,
45
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET, // server-side only
46
+ resolveUser: req => {
47
+ const user = req.session?.user; // your auth, unchanged
48
+ if (!user) return null; // 401 IDENTITY_REQUIRED
49
+ return { externalId: \`${namespace}:\${user.id}\`, displayName: user.name };
50
+ },
51
+ });
52
+
53
+ ${mount}
54
+ `;
55
+ }
56
+ function bootFile(options) {
57
+ const lines = [
58
+ "import { bootAppilotWidget } from 'appilot';",
59
+ '',
60
+ '// Call once, after your app knows the user is signed in. The widget',
61
+ '// requires an identified user; the relay below mints that identity.',
62
+ 'export function startAppilot() {',
63
+ ' bootAppilotWidget({',
64
+ ` widgetScriptUrl: '${options.widgetScriptUrl}',`,
65
+ ];
66
+ if (options.widgetKey)
67
+ lines.push(` widgetKey: '${options.widgetKey}',`);
68
+ if (options.apiUrl)
69
+ lines.push(` appilotApiUrl: '${options.apiUrl}',`);
70
+ lines.push(" tokenEndpoint: '/api/widget/token',", ' });', '}');
71
+ return lines.join('\n') + '\n';
72
+ }
73
+ const CLIENT_ACTION_EXAMPLE = `import { registerTool } from 'appilot';
74
+
75
+ // A client action is an operation your PAGE performs, in the user's own
76
+ // session. Use it for anything UI-coupled or session-bound: navigate the SPA,
77
+ // open a modal, or a mutation your page already knows how to do safely.
78
+ // Backend operations belong in an HTTP-proxy tool instead.
79
+ export function registerBookingActions(navigate: (path: string) => void) {
80
+ const handle = registerTool({
81
+ name: 'open_booking',
82
+ description: 'Open a booking by id so the user can see it.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: { booking_id: { type: 'string' } },
86
+ required: ['booking_id'],
87
+ },
88
+ annotations: { readOnlyHint: true }, // absent or false means mutating,
89
+ // and the agent confirms first
90
+ async execute({ booking_id }) {
91
+ navigate(\`/bookings/\${booking_id}\`);
92
+ return { content: [{ type: 'text', text: \`Opened booking \${booking_id}.\` }] };
93
+ },
94
+ });
95
+
96
+ return () => handle.unregister(); // unregister on route change
97
+ }
98
+ `;
99
+ export function scaffoldIntegration(options) {
100
+ const namespace = options.idNamespace ?? 'app';
101
+ const files = [];
102
+ switch (options.framework) {
103
+ case 'next':
104
+ files.push({
105
+ path: 'app/api/widget/token/route.ts',
106
+ language: 'typescript',
107
+ contents: relayFetchHandler(namespace),
108
+ });
109
+ break;
110
+ case 'remix':
111
+ files.push({
112
+ path: 'app/routes/api.widget.token.ts',
113
+ language: 'typescript',
114
+ contents: `import { createWidgetTokenHandler } from 'appilot-server';
115
+
116
+ const handler = createWidgetTokenHandler({
117
+ apiUrl: process.env.APPILOT_API_URL!,
118
+ widgetKey: process.env.APPILOT_WIDGET_KEY!,
119
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET!,
120
+ resolveUser: async request => {
121
+ const session = await getSession(request.headers.get('Cookie'));
122
+ const userId = session.get('userId');
123
+ return userId ? { externalId: \`${namespace}:\${userId}\` } : null;
124
+ },
125
+ });
126
+
127
+ export const action = ({ request }: { request: Request }) => handler(request);
128
+ `,
129
+ });
130
+ break;
131
+ case 'sveltekit':
132
+ files.push({
133
+ path: 'src/routes/api/widget/token/+server.ts',
134
+ language: 'typescript',
135
+ contents: `import { createWidgetTokenHandler } from 'appilot-server';
136
+ import { env } from '$env/dynamic/private';
137
+
138
+ const handler = createWidgetTokenHandler({
139
+ apiUrl: env.APPILOT_API_URL,
140
+ widgetKey: env.APPILOT_WIDGET_KEY,
141
+ widgetSecret: env.APPILOT_WIDGET_SECRET,
142
+ resolveUser: async request => {
143
+ const user = await getUserFromCookies(request.headers.get('cookie'));
144
+ return user ? { externalId: \`${namespace}:\${user.id}\`, displayName: user.name } : null;
145
+ },
146
+ });
147
+
148
+ export const POST = ({ request }) => handler(request);
149
+ `,
150
+ });
151
+ break;
152
+ case 'hono':
153
+ files.push({
154
+ path: 'src/routes/widgetToken.ts',
155
+ language: 'typescript',
156
+ contents: `import { Hono } from 'hono';
157
+ import { createWidgetTokenHandler } from 'appilot-server';
158
+
159
+ const handler = createWidgetTokenHandler({
160
+ apiUrl: process.env.APPILOT_API_URL!,
161
+ widgetKey: process.env.APPILOT_WIDGET_KEY!,
162
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET!,
163
+ resolveUser: async request => {
164
+ const user = await getUser(request);
165
+ return user ? { externalId: \`${namespace}:\${user.id}\` } : null;
166
+ },
167
+ });
168
+
169
+ export const widgetToken = new Hono().post('/api/widget/token', c => handler(c.req.raw));
170
+ `,
171
+ });
172
+ break;
173
+ case 'express':
174
+ case 'fastify':
175
+ files.push({
176
+ path: 'src/routes/widgetToken.ts',
177
+ language: 'typescript',
178
+ contents: relayNodeHandler(namespace, options.framework),
179
+ });
180
+ break;
181
+ }
182
+ files.push({ path: 'src/appilot/boot.ts', language: 'typescript', contents: bootFile(options) });
183
+ files.push({
184
+ path: 'src/appilot/actions.ts',
185
+ language: 'typescript',
186
+ contents: CLIENT_ACTION_EXAMPLE,
187
+ });
188
+ return {
189
+ framework: options.framework,
190
+ files,
191
+ install: 'npm install appilot appilot-server',
192
+ env: [
193
+ `APPILOT_API_URL=${options.apiUrl ?? 'https://api.appilot.space'}`,
194
+ `APPILOT_WIDGET_KEY=${options.widgetKey ?? 'wk_live_...'}`,
195
+ 'APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle',
196
+ ].join('\n'),
197
+ notes: [
198
+ 'The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.',
199
+ 'resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.',
200
+ 'Call verify_integration once this is wired to confirm the relay answers and the widget actually boots.',
201
+ ],
202
+ };
203
+ }