appilot-mcp 0.1.0 → 0.2.1

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.
@@ -1,50 +1,57 @@
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
- */
1
+ import { message, translateError } from './consentMessages.js';
2
+ /** Server-rendered consent stays usable without JavaScript or third-party scripts. */
14
3
  const STYLE = `
15
- :root { color-scheme: light dark; }
4
+ :root { color-scheme: light dark; --bg: 210 20% 98%; --card: 0 0% 100%; --text: 222 47% 11%; --muted: 215 16% 38%; --line: 214 25% 85%; --accent: 193 100% 30%; --tint: 193 65% 95%; --error: 0 74% 35%; }
16
5
  * { 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; }
6
+ body { margin: 0; background: hsl(var(--bg)); color: hsl(var(--text)); font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
7
+ a { color: hsl(var(--accent)); text-underline-offset: .2em; }
8
+ .shell { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); }
9
+ .visual { display: none; }
10
+ main { width: 100%; max-width: 42rem; margin: auto; padding: clamp(1.25rem, 5vw, 3.5rem); }
11
+ .brand { display: block; width: 136px; height: 44px; object-fit: contain; object-position: left; margin-bottom: 2rem; }
12
+ .eyebrow { color: hsl(var(--accent)); font-size: .8rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
13
+ h1 { font-size: clamp(1.65rem, 4vw, 2.15rem); letter-spacing: -.035em; line-height: 1.18; margin: .65rem 0 1rem; overflow-wrap: anywhere; }
14
+ h2 { font-size: 1rem; margin: 0 0 .6rem; }
15
+ p { margin: 0 0 1.25rem; color: hsl(var(--muted)); }
16
+ strong { color: hsl(var(--text)); }
17
+ .steps { display: flex; gap: 1.5rem; padding: 0; margin: 0 0 2rem; list-style: none; font-size: .8rem; color: hsl(var(--muted)); }
18
+ .steps [aria-current=step] { color: hsl(var(--accent)); font-weight: 650; }
19
+ .panel { padding: 1.2rem; border: 1px solid hsl(var(--line)); border-radius: .75rem; margin: 1.25rem 0; background: hsl(var(--card)); }
20
+ .help { background: hsl(var(--tint)); border-color: transparent; }
21
+ .help p { font-size: .875rem; margin-bottom: .75rem; }
22
+ .help a { display: inline-flex; align-items: center; min-height: 44px; font-weight: 650; }
23
+ label { display: block; font-weight: 600; margin: 1.25rem 0 .5rem; }
24
+ input[type=password] { width: 100%; min-height: 48px; padding: .75rem; font: inherit; border: 1px solid hsl(var(--line)); border-radius: .5rem; background: hsl(var(--card)); color: inherit; }
25
+ :focus-visible { outline: 3px solid hsl(var(--accent)); outline-offset: 3px; }
26
+ small, .small { display: block; font-size: .8125rem; color: hsl(var(--muted)); margin-top: .5rem; }
27
+ .scopes { list-style: none; margin: 0; padding: 0; }
28
+ .scopes li { padding: .8rem 0; border-bottom: 1px solid hsl(var(--line)); }
29
+ .scopes li:last-child { border-bottom: 0; }
30
+ .scopes small { margin-top: .2rem; }
31
+ dl { margin: 0; }
32
+ dl > div { padding: .65rem 0; }
33
+ dt { font-size: .8rem; color: hsl(var(--muted)); }
34
+ dd { margin: .2rem 0 0; font-weight: 600; overflow-wrap: anywhere; }
35
+ .actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.5rem; }
36
+ button { flex: 1; min-height: 48px; padding: .75rem 1rem; font: inherit; font-weight: 650; border-radius: .5rem; cursor: pointer; border: 1px solid hsl(var(--line)); background: hsl(var(--card)); color: inherit; }
37
+ button.approve { flex: 2; background: hsl(var(--accent)); color: hsl(var(--card)); border-color: transparent; }
38
+ button:hover { filter: brightness(.95); }
39
+ .error { color: hsl(var(--error)); border-left: 3px solid currentColor; padding: .8rem 1rem; margin: 1rem 0; background: hsl(var(--card)); }
40
+ details { margin-top: 1.25rem; }
41
+ summary { cursor: pointer; min-height: 44px; padding: .6rem 0; font-size: .875rem; color: hsl(var(--muted)); }
42
+ footer { margin-top: 2rem; border-top: 1px solid hsl(var(--line)); padding-top: 1rem; }
43
+ @media (min-width: 1100px) {
44
+ .shell.with-visual { grid-template-columns: minmax(320px, .85fr) minmax(600px, 1.15fr); }
45
+ .visual { display: block; position: sticky; top: 0; height: 100svh; overflow: hidden; background: hsl(224 25% 10%); }
46
+ .visual img { width: 100%; height: 100%; object-fit: cover; }
47
+ }
35
48
  @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; }
49
+ :root { --bg: 224 15% 7%; --card: 220 16% 11%; --text: 220 13% 93%; --muted: 218 12% 73%; --line: 220 10% 32%; --accent: 193 80% 70%; --tint: 200 25% 14%; --error: 0 90% 80%; }
50
+ button.approve { color: hsl(224 15% 7%); }
45
51
  }
52
+ @media (forced-colors: active) { button.approve { border: 1px solid ButtonText; } }
46
53
  `;
47
- function escapeHtml(value) {
54
+ export function escapeHtml(value) {
48
55
  return value
49
56
  .replace(/&/g, '&')
50
57
  .replace(/</g, '&lt;')
@@ -52,48 +59,64 @@ function escapeHtml(value) {
52
59
  .replace(/"/g, '&quot;')
53
60
  .replace(/'/g, '&#39;');
54
61
  }
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>`;
62
+ function page(title, body, backofficeUrl, locale = 'en') {
63
+ const asset = (path) => escapeHtml(new URL(path, backofficeUrl).toString());
64
+ return `<!doctype html><html lang="${locale}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escapeHtml(title)} | Appilot</title><style>${STYLE}</style></head>
65
+ <body><div class="shell ${backofficeUrl ? 'with-visual' : ''}">${backofficeUrl ? `<aside class="visual" aria-hidden="true"><img src="${asset('/auth-visual.webp')}" alt="" referrerpolicy="no-referrer"></aside>` : ''}<main>${backofficeUrl ? `<img class="brand" src="${asset('/logo.svg')}" alt="Appilot" referrerpolicy="no-referrer">` : '<div class="eyebrow">Appilot</div>'}${body}</main></div></body></html>`;
63
66
  }
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
+ const scopeKeys = {
68
+ 'config:read': ['read', 'readHelp'],
69
+ 'config:write': ['write', 'writeHelp'],
70
+ 'provision:write': ['provision', 'provisionHelp'],
71
+ 'feedback:write': ['feedback', 'feedbackHelp'],
67
72
  };
73
+ function scopeTitle(scope, locale) {
74
+ const keys = scopeKeys[scope];
75
+ return keys ? message(keys[0], locale) : scope;
76
+ }
77
+ function scopeItems(scopes, locale) {
78
+ return `<ul class="scopes">${scopes
79
+ .map((scope) => {
80
+ const keys = scopeKeys[scope];
81
+ return `<li><strong>${escapeHtml(scopeTitle(scope, locale))}</strong><small>${keys ? escapeHtml(message(keys[1], locale)) : ''}</small></li>`;
82
+ })
83
+ .join('')}</ul>`;
84
+ }
85
+ function steps(step, locale) {
86
+ return `<ol class="steps" aria-label="${message('progress', locale)}"><li ${step === 1 ? 'aria-current="step"' : ''}>${message('verifyStep', locale)}</li><li ${step === 2 ? 'aria-current="step"' : ''}>${message('reviewStep', locale)}</li></ol>`;
87
+ }
88
+ function managementLink(url) {
89
+ return escapeHtml(new URL('/service-tokens', url).toString());
90
+ }
68
91
  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>`);
92
+ const locale = opts.locale ?? 'en';
93
+ const m = (key, values) => escapeHtml(message(key, locale, values));
94
+ return page(message('connect', locale, { client: opts.clientName }), `${steps(1, locale)}<h1>${m('connect', { client: opts.clientName })}</h1><p>${m('intro')}</p>
95
+ <div class="panel help"><h2>${m('first')}</h2><p>${m('tokenIntro')}</p>${opts.backofficeUrl ? `<a href="${managementLink(opts.backofficeUrl)}" target="_blank" rel="noopener noreferrer">${m('open')} <span aria-hidden="true"> ↗</span></a><small>${m('openHelp')}</small>` : `<small>${m('privateHelp')}</small>`}</div>
96
+ ${opts.error ? `<div class="error" role="alert" id="token-error">${escapeHtml(opts.error)}</div>` : ''}
97
+ <form method="post" action="${escapeHtml(opts.action)}"><input type="hidden" name="request" value="${escapeHtml(opts.request)}">
98
+ <label for="pat">${m('token')}</label><input id="pat" name="pat" type="password" autocomplete="off" spellcheck="false" required placeholder="appilot_pat_…" aria-describedby="pat-help${opts.error ? ' token-error' : ''}" ${opts.error ? 'aria-invalid="true"' : ''}>
99
+ <small id="pat-help">${m('paste')}</small><details><summary>${m('requested')}</summary><p class="small">${m('next')}</p>${scopeItems(opts.scopes, locale)}</details>
100
+ <div class="actions"><button type="submit" name="action" value="deny" formnovalidate>${m('cancel')}</button><button class="approve" type="submit" name="action" value="approve">${m('review')}</button></div></form>
101
+ <footer><small>${m('footer', { url: opts.baseUrl })}</small></footer>`, opts.backofficeUrl, locale);
102
+ }
103
+ export function renderConfirmPage(opts) {
104
+ const locale = opts.locale ?? 'en';
105
+ const m = (key, values) => escapeHtml(message(key, locale, values));
106
+ const organization = opts.organizationName ||
107
+ (opts.organizationId != null
108
+ ? `${message('organization', locale)} #${opts.organizationId}`
109
+ : message('unknown', locale));
110
+ const app = opts.appName || (opts.appId != null ? `App #${opts.appId}` : message('allApps', locale));
111
+ return page(message('review', locale), `${steps(2, locale)}<h1>${m('reviewFor', { client: opts.clientName || 'AI assistant' })}</h1><p>${m('accepted')}</p>
112
+ <div class="panel"><dl><div><dt>${m('organization')}</dt><dd>${escapeHtml(organization)}</dd></div><div><dt>${m(opts.appScoped ? 'limited' : 'appAccess')}</dt><dd>${escapeHtml(app)}</dd></div></dl>${opts.appId != null && !opts.appScoped ? `<small>${m('defaultNote')}</small>` : ''}</div>
113
+ <h2>${m('can')}</h2>${scopeItems(opts.scopes, locale)}
114
+ ${opts.unavailableScopes?.length ? `<div class="panel help"><h2>${m('unavailable')}</h2><p class="small">${m('unavailableHelp', { scopes: opts.unavailableScopes.map((scope) => scopeTitle(scope, locale)).join(', ') })}</p></div>` : ''}
115
+ ${opts.withheldScopes.length ? `<details><summary>${m('excluded')}</summary>${scopeItems(opts.withheldScopes, locale)}</details>` : ''}
116
+ <form method="post" action="${escapeHtml(opts.action)}"><input type="hidden" name="request" value="${escapeHtml(opts.request)}"><div class="actions"><button type="submit" name="action" value="back">${m('back')}</button><button class="approve" type="submit" name="action" value="confirm">${m('allow')}</button></div><button style="margin-top:.75rem;width:100%" type="submit" name="action" value="deny">${m('cancelConnection')}</button></form>
117
+ <footer><small>${m('stop')} ${opts.backofficeUrl ? `<a href="${managementLink(opts.backofficeUrl)}" target="_blank" rel="noopener noreferrer">${m('open')}</a>` : ''}</small></footer>`, opts.backofficeUrl, locale);
96
118
  }
97
- export function renderErrorPage(title, detail) {
98
- return page(title, `<h1>${escapeHtml(title)}</h1><div class="error">${escapeHtml(detail)}</div>`);
119
+ export function renderErrorPage(title, detail, locale = 'en') {
120
+ const translatedTitle = locale === 'en' ? title : translateError(title, locale);
121
+ return page(translatedTitle, `<h1>${escapeHtml(translatedTitle)}</h1><div class="error" role="alert">${escapeHtml(locale === 'en' ? detail : translateError(detail, locale))}</div><p>${escapeHtml(message('restart', locale))}</p>`, undefined, locale);
99
122
  }
@@ -0,0 +1,62 @@
1
+ export type ConsentLocale = 'en' | 'es' | 'de';
2
+ declare const en: {
3
+ readonly sessionUnavailable: "Account approval is temporarily unavailable. Try connecting again, or use a service token below.";
4
+ readonly connect: "Connect {client} to Appilot";
5
+ readonly intro: "Choose what your assistant can access. You will review the connection before granting permission.";
6
+ readonly first: "First time connecting?";
7
+ readonly tokenIntro: "Create a service token in Appilot. It is a private access key with the permissions and expiry you choose.";
8
+ readonly open: "Open service tokens";
9
+ readonly openHelp: "Opens in a new tab using your Backoffice session. An organization administrator must create the token. Keep this tab open.";
10
+ readonly privateHelp: "Open your instance's Backoffice, then Service tokens. An organization administrator must create the token.";
11
+ readonly token: "Service token";
12
+ readonly paste: "Paste the token you just created. Appilot will detect its organization and app automatically. Do not paste it in a chat.";
13
+ readonly requested: "Permissions this client is requesting";
14
+ readonly next: "The next step shows which of these permissions your token actually allows.";
15
+ readonly cancel: "Cancel";
16
+ readonly review: "Review connection";
17
+ readonly back: "Back";
18
+ readonly allow: "Allow access";
19
+ readonly cancelConnection: "Cancel connection";
20
+ readonly footer: "Connecting to {url}. You can revoke the token in Appilot to stop this connection.";
21
+ readonly progress: "Connection progress";
22
+ readonly verifyStep: "1. Verify token";
23
+ readonly reviewStep: "2. Review access";
24
+ readonly reviewFor: "Review access for {client}";
25
+ readonly accepted: "Your token was accepted. Confirm the organization and permissions below.";
26
+ readonly organization: "Organization";
27
+ readonly unknown: "Not reported by this instance";
28
+ readonly allApps: "All apps in this organization";
29
+ readonly limited: "Access limited to";
30
+ readonly appAccess: "App access";
31
+ readonly defaultNote: "This is a default app, not an access restriction. The token can still access other apps in the organization.";
32
+ readonly can: "This connection can";
33
+ readonly unavailable: "Some requested permissions are unavailable";
34
+ readonly unavailableHelp: "Your token does not allow: {scopes}. You can connect with the permissions above, or go back and use a different token.";
35
+ readonly excluded: "Permissions excluded from this connection";
36
+ readonly stop: "Access stops when the service token expires or you revoke it in Appilot.";
37
+ readonly restart: "Return to the app you were connecting and start the connection again.";
38
+ readonly read: "Read configuration";
39
+ readonly readHelp: "Inspect and audit your apps, including their knowledge content.";
40
+ readonly write: "Edit configuration";
41
+ readonly writeHelp: "Create, change, import, and delete configuration and knowledge content.";
42
+ readonly provision: "Set up apps and integrations";
43
+ readonly provisionHelp: "Create apps, manage domains, and issue widget keys within the token's access.";
44
+ readonly feedback: "Send issue reports to Appilot";
45
+ readonly feedbackHelp: "Share a report title, description, and technical context with Appilot. This sends information outside your organization.";
46
+ readonly expired: "This sign-in link expired";
47
+ readonly expiredHelp: "Start the connection again from the client that sent you here. A consent link is valid for ten minutes.";
48
+ readonly invalid: "Invalid request";
49
+ readonly invalidHelp: "That confirmation is incomplete. Start again.";
50
+ readonly wrongToken: "That does not look like a service token. Create one on the Service tokens page in your Backoffice.";
51
+ readonly verifyError: "We could not verify the token right now. Please try again.";
52
+ readonly rejected: "The instance rejected that service token. It may have been revoked or have expired.";
53
+ readonly noScopes: "The token does not include any requested permission. Use a token with the access you need.";
54
+ readonly failed: "Connection could not be completed";
55
+ readonly failedHelp: "The approval expired, was already used, or belongs to another browser. Start again from your assistant.";
56
+ readonly unavailableTitle: "Connection unavailable";
57
+ readonly unavailableDetail: "Appilot could not complete the connection. Please try again.";
58
+ };
59
+ export declare function consentLocale(header?: string): ConsentLocale;
60
+ export declare function message(key: keyof typeof en, locale?: ConsentLocale, values?: Record<string, string>): string;
61
+ export declare function translateError(text: string, locale: ConsentLocale): string;
62
+ export {};
@@ -0,0 +1,196 @@
1
+ const en = {
2
+ sessionUnavailable: 'Account approval is temporarily unavailable. Try connecting again, or use a service token below.',
3
+ connect: 'Connect {client} to Appilot',
4
+ intro: 'Choose what your assistant can access. You will review the connection before granting permission.',
5
+ first: 'First time connecting?',
6
+ tokenIntro: 'Create a service token in Appilot. It is a private access key with the permissions and expiry you choose.',
7
+ open: 'Open service tokens',
8
+ openHelp: 'Opens in a new tab using your Backoffice session. An organization administrator must create the token. Keep this tab open.',
9
+ privateHelp: "Open your instance's Backoffice, then Service tokens. An organization administrator must create the token.",
10
+ token: 'Service token',
11
+ paste: 'Paste the token you just created. Appilot will detect its organization and app automatically. Do not paste it in a chat.',
12
+ requested: 'Permissions this client is requesting',
13
+ next: 'The next step shows which of these permissions your token actually allows.',
14
+ cancel: 'Cancel',
15
+ review: 'Review connection',
16
+ back: 'Back',
17
+ allow: 'Allow access',
18
+ cancelConnection: 'Cancel connection',
19
+ footer: 'Connecting to {url}. You can revoke the token in Appilot to stop this connection.',
20
+ progress: 'Connection progress',
21
+ verifyStep: '1. Verify token',
22
+ reviewStep: '2. Review access',
23
+ reviewFor: 'Review access for {client}',
24
+ accepted: 'Your token was accepted. Confirm the organization and permissions below.',
25
+ organization: 'Organization',
26
+ unknown: 'Not reported by this instance',
27
+ allApps: 'All apps in this organization',
28
+ limited: 'Access limited to',
29
+ appAccess: 'App access',
30
+ defaultNote: 'This is a default app, not an access restriction. The token can still access other apps in the organization.',
31
+ can: 'This connection can',
32
+ unavailable: 'Some requested permissions are unavailable',
33
+ unavailableHelp: 'Your token does not allow: {scopes}. You can connect with the permissions above, or go back and use a different token.',
34
+ excluded: 'Permissions excluded from this connection',
35
+ stop: 'Access stops when the service token expires or you revoke it in Appilot.',
36
+ restart: 'Return to the app you were connecting and start the connection again.',
37
+ read: 'Read configuration',
38
+ readHelp: 'Inspect and audit your apps, including their knowledge content.',
39
+ write: 'Edit configuration',
40
+ writeHelp: 'Create, change, import, and delete configuration and knowledge content.',
41
+ provision: 'Set up apps and integrations',
42
+ provisionHelp: "Create apps, manage domains, and issue widget keys within the token's access.",
43
+ feedback: 'Send issue reports to Appilot',
44
+ feedbackHelp: 'Share a report title, description, and technical context with Appilot. This sends information outside your organization.',
45
+ expired: 'This sign-in link expired',
46
+ expiredHelp: 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.',
47
+ invalid: 'Invalid request',
48
+ invalidHelp: 'That confirmation is incomplete. Start again.',
49
+ wrongToken: 'That does not look like a service token. Create one on the Service tokens page in your Backoffice.',
50
+ verifyError: 'We could not verify the token right now. Please try again.',
51
+ rejected: 'The instance rejected that service token. It may have been revoked or have expired.',
52
+ noScopes: 'The token does not include any requested permission. Use a token with the access you need.',
53
+ failed: 'Connection could not be completed',
54
+ failedHelp: 'The approval expired, was already used, or belongs to another browser. Start again from your assistant.',
55
+ unavailableTitle: 'Connection unavailable',
56
+ unavailableDetail: 'Appilot could not complete the connection. Please try again.',
57
+ };
58
+ const es = {
59
+ sessionUnavailable: 'La aprobación desde tu cuenta no está disponible temporalmente. Intenta conectar otra vez o usa un token de servicio abajo.',
60
+ connect: 'Conectar {client} a Appilot',
61
+ intro: 'Elige a qué puede acceder tu asistente. Revisarás la conexión antes de permitir el acceso.',
62
+ first: '¿Es tu primera conexión?',
63
+ tokenIntro: 'Crea un token de servicio en Appilot. Es una clave privada con los permisos y el vencimiento que elijas.',
64
+ open: 'Abrir tokens de servicio',
65
+ openHelp: 'Se abre en otra pestaña con tu sesión del Backoffice. Una persona administradora debe crear el token. Mantén esta pestaña abierta.',
66
+ privateHelp: 'Abre el Backoffice de tu instancia y luego Tokens de servicio. Una persona administradora debe crear el token.',
67
+ token: 'Token de servicio',
68
+ paste: 'Pega el token que creaste. Appilot detectará su organización y aplicación. No lo pegues en un chat.',
69
+ requested: 'Permisos que solicita este cliente',
70
+ next: 'El siguiente paso muestra cuáles de estos permisos permite tu token.',
71
+ cancel: 'Cancelar',
72
+ review: 'Revisar conexión',
73
+ back: 'Volver',
74
+ allow: 'Permitir acceso',
75
+ cancelConnection: 'Cancelar conexión',
76
+ footer: 'Conexión con {url}. Puedes revocar el token en Appilot para detener esta conexión.',
77
+ progress: 'Progreso de la conexión',
78
+ verifyStep: '1. Verificar token',
79
+ reviewStep: '2. Revisar acceso',
80
+ reviewFor: 'Revisar acceso para {client}',
81
+ accepted: 'Tu token fue aceptado. Confirma la organización y los permisos.',
82
+ organization: 'Organización',
83
+ unknown: 'Esta instancia no lo informa',
84
+ allApps: 'Todas las aplicaciones de esta organización',
85
+ limited: 'Acceso limitado a',
86
+ appAccess: 'Acceso a aplicaciones',
87
+ defaultNote: 'Es una aplicación predeterminada; el token también permite acceder a otras aplicaciones de la organización.',
88
+ can: 'Esta conexión permite',
89
+ unavailable: 'Algunos permisos solicitados no están disponibles',
90
+ unavailableHelp: 'Tu token no permite: {scopes}. Puedes conectar con los permisos indicados o volver y usar otro token.',
91
+ excluded: 'Permisos excluidos de esta conexión',
92
+ stop: 'El acceso termina cuando el token vence o lo revocas en Appilot.',
93
+ restart: 'Vuelve a la aplicación que estabas conectando e inicia la conexión de nuevo.',
94
+ read: 'Leer configuración',
95
+ readHelp: 'Inspeccionar y auditar las aplicaciones y su contenido de conocimiento.',
96
+ write: 'Editar configuración',
97
+ writeHelp: 'Crear, cambiar, importar y eliminar configuración y conocimiento.',
98
+ provision: 'Configurar aplicaciones e integraciones',
99
+ provisionHelp: 'Crear aplicaciones, gestionar dominios y emitir claves de widget dentro del acceso del token.',
100
+ feedback: 'Enviar reportes de problemas a Appilot',
101
+ feedbackHelp: 'Compartir un título, una descripción y contexto técnico con Appilot. Envía información fuera de tu organización.',
102
+ expired: 'Este enlace de acceso venció',
103
+ expiredHelp: 'Inicia de nuevo la conexión desde el cliente que te envió aquí. El enlace es válido durante diez minutos.',
104
+ invalid: 'Solicitud inválida',
105
+ invalidHelp: 'La confirmación está incompleta. Vuelve a empezar.',
106
+ wrongToken: 'No parece un token de servicio. Crea uno en Tokens de servicio en el Backoffice.',
107
+ verifyError: 'No pudimos verificar el token. Inténtalo de nuevo.',
108
+ rejected: 'La instancia rechazó el token. Puede haber sido revocado o haber vencido.',
109
+ noScopes: 'El token no incluye ningún permiso solicitado. Usa uno con el acceso que necesitas.',
110
+ failed: 'No se pudo completar la conexión',
111
+ failedHelp: 'La aprobación venció, ya se utilizó o pertenece a otro navegador. Vuelve a empezar desde tu asistente.',
112
+ unavailableTitle: 'Conexión no disponible',
113
+ unavailableDetail: 'Appilot no pudo completar la conexión. Inténtalo de nuevo.',
114
+ };
115
+ const de = {
116
+ sessionUnavailable: 'Die Freigabe über Ihr Konto ist vorübergehend nicht verfügbar. Starten Sie erneut oder verwenden Sie unten ein Service-Token.',
117
+ connect: '{client} mit Appilot verbinden',
118
+ intro: 'Wählen Sie den Zugriff Ihres Assistenten. Vor der Freigabe prüfen Sie die Verbindung.',
119
+ first: 'Zum ersten Mal verbinden?',
120
+ tokenIntro: 'Erstellen Sie ein Service-Token in Appilot. Es ist ein privater Zugangsschlüssel mit den gewählten Berechtigungen und dem Ablaufdatum.',
121
+ open: 'Service-Tokens öffnen',
122
+ openHelp: 'Öffnet einen neuen Tab mit Ihrer Backoffice-Sitzung. Ein Administrator muss das Token erstellen. Lassen Sie diesen Tab geöffnet.',
123
+ privateHelp: 'Öffnen Sie das Backoffice Ihrer Instanz und dann Service-Tokens. Ein Administrator muss das Token erstellen.',
124
+ token: 'Service-Token',
125
+ paste: 'Fügen Sie das erstellte Token ein. Appilot erkennt Organisation und Anwendung. Fügen Sie es nicht in einen Chat ein.',
126
+ requested: 'Vom Client angeforderte Berechtigungen',
127
+ next: 'Im nächsten Schritt sehen Sie, welche Berechtigungen Ihr Token erlaubt.',
128
+ cancel: 'Abbrechen',
129
+ review: 'Verbindung prüfen',
130
+ back: 'Zurück',
131
+ allow: 'Zugriff erlauben',
132
+ cancelConnection: 'Verbindung abbrechen',
133
+ footer: 'Verbindung mit {url}. Sie können das Token in Appilot widerrufen, um die Verbindung zu beenden.',
134
+ progress: 'Verbindungsfortschritt',
135
+ verifyStep: '1. Token prüfen',
136
+ reviewStep: '2. Zugriff prüfen',
137
+ reviewFor: 'Zugriff für {client} prüfen',
138
+ accepted: 'Ihr Token wurde akzeptiert. Bestätigen Sie Organisation und Berechtigungen.',
139
+ organization: 'Organisation',
140
+ unknown: 'Von dieser Instanz nicht angegeben',
141
+ allApps: 'Alle Anwendungen dieser Organisation',
142
+ limited: 'Zugriff beschränkt auf',
143
+ appAccess: 'Anwendungszugriff',
144
+ defaultNote: 'Dies ist eine Standardanwendung. Das Token kann auch auf andere Anwendungen der Organisation zugreifen.',
145
+ can: 'Diese Verbindung erlaubt',
146
+ unavailable: 'Einige angeforderte Berechtigungen sind nicht verfügbar',
147
+ unavailableHelp: 'Ihr Token erlaubt nicht: {scopes}. Verbinden Sie sich mit den angezeigten Berechtigungen oder verwenden Sie ein anderes Token.',
148
+ excluded: 'Ausgeschlossene Berechtigungen',
149
+ stop: 'Der Zugriff endet, wenn das Token abläuft oder Sie es in Appilot widerrufen.',
150
+ restart: 'Kehren Sie zur Anwendung zurück und starten Sie die Verbindung erneut.',
151
+ read: 'Konfiguration lesen',
152
+ readHelp: 'Anwendungen und Wissensinhalte prüfen.',
153
+ write: 'Konfiguration bearbeiten',
154
+ writeHelp: 'Konfiguration und Wissensinhalte erstellen, ändern, importieren und löschen.',
155
+ provision: 'Anwendungen und Integrationen einrichten',
156
+ provisionHelp: 'Anwendungen erstellen, Domains verwalten und Widget-Schlüssel im Zugriffsumfang des Tokens ausstellen.',
157
+ feedback: 'Problemberichte an Appilot senden',
158
+ feedbackHelp: 'Titel, Beschreibung und technischen Kontext mit Appilot teilen. Dabei verlassen Informationen Ihre Organisation.',
159
+ expired: 'Dieser Anmeldelink ist abgelaufen',
160
+ expiredHelp: 'Starten Sie die Verbindung erneut im ursprünglichen Client. Der Link ist zehn Minuten gültig.',
161
+ invalid: 'Ungültige Anfrage',
162
+ invalidHelp: 'Die Bestätigung ist unvollständig. Starten Sie erneut.',
163
+ wrongToken: 'Dies scheint kein Service-Token zu sein. Erstellen Sie eines unter Service-Tokens im Backoffice.',
164
+ verifyError: 'Das Token konnte nicht geprüft werden. Versuchen Sie es erneut.',
165
+ rejected: 'Die Instanz hat das Token abgelehnt. Es wurde möglicherweise widerrufen oder ist abgelaufen.',
166
+ noScopes: 'Das Token enthält keine angeforderte Berechtigung. Verwenden Sie eines mit dem benötigten Zugriff.',
167
+ failed: 'Verbindung konnte nicht abgeschlossen werden',
168
+ failedHelp: 'Die Freigabe ist abgelaufen, wurde bereits verwendet oder gehört zu einem anderen Browser. Starten Sie erneut in Ihrem Assistenten.',
169
+ unavailableTitle: 'Verbindung nicht verfügbar',
170
+ unavailableDetail: 'Appilot konnte die Verbindung nicht abschließen. Versuchen Sie es erneut.',
171
+ };
172
+ export function consentLocale(header = '') {
173
+ const preferences = header
174
+ .split(',')
175
+ .map((part) => {
176
+ const [tag, weight] = part.trim().split(';q=');
177
+ return {
178
+ tag: tag.toLowerCase().split('-')[0],
179
+ q: weight === undefined ? 1 : Number(weight),
180
+ };
181
+ })
182
+ .filter((item) => item.q > 0)
183
+ .sort((a, b) => b.q - a.q);
184
+ return (preferences.find((item) => ['en', 'es', 'de'].includes(item.tag))?.tag ??
185
+ 'en');
186
+ }
187
+ export function message(key, locale = 'en', values = {}) {
188
+ const template = ({ en, es, de }[locale] ?? en)[key];
189
+ return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? `{${name}}`);
190
+ }
191
+ export function translateError(text, locale) {
192
+ const entry = Object.entries(en).find(([, value]) => value === text);
193
+ return entry
194
+ ? message(entry[0], locale)
195
+ : message('unavailableDetail', locale);
196
+ }
@@ -0,0 +1,10 @@
1
+ import type { Response } from 'express';
2
+ export declare const digest: (value: string) => string;
3
+ export declare const cookieName: (id: string) => string;
4
+ export declare function connectionRequest(baseUrl: string, secret: string, path: string, body: unknown): Promise<Record<string, any>>;
5
+ export declare function startConnection(options: {
6
+ baseUrl: string;
7
+ secret: string;
8
+ backofficeUrl: string;
9
+ secure: boolean;
10
+ }, requestState: string, clientId: string, clientName: string, scopes: string[], language: string, res: Response): Promise<void>;
@@ -0,0 +1,44 @@
1
+ /** Server-to-server approval exchange. The browser carries only a one-use code. */
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ export const digest = (value) => createHash('sha256').update(value).digest('hex');
4
+ export const cookieName = (id) => `appilot_connection_${id}`;
5
+ export async function connectionRequest(baseUrl, secret, path, body) {
6
+ const response = await fetch(`${baseUrl}/mcp-connections/${path}`, {
7
+ method: 'POST',
8
+ headers: {
9
+ 'Content-Type': 'application/json',
10
+ 'X-Appilot-Handoff': secret,
11
+ },
12
+ body: JSON.stringify(body),
13
+ signal: AbortSignal.timeout(10000),
14
+ redirect: 'error',
15
+ });
16
+ if (!response.ok)
17
+ throw new Error('Connection approval unavailable');
18
+ return response.json();
19
+ }
20
+ export async function startConnection(options, requestState, clientId, clientName, scopes, language, res) {
21
+ const proof = randomBytes(32).toString('hex');
22
+ const { id } = await connectionRequest(options.baseUrl, options.secret, 'requests', {
23
+ requestState,
24
+ requestHash: digest(requestState),
25
+ browserHash: digest(proof),
26
+ clientHash: digest(clientId),
27
+ clientName: clientName.slice(0, 100),
28
+ scopes,
29
+ language,
30
+ });
31
+ if (typeof id !== 'string' ||
32
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))
33
+ throw new Error('Invalid connection response');
34
+ res.cookie(cookieName(id), proof, {
35
+ httpOnly: true,
36
+ secure: options.secure,
37
+ sameSite: 'lax',
38
+ maxAge: 600000,
39
+ path: '/connect/callback',
40
+ });
41
+ const destination = new URL('/connections/authorize', options.backofficeUrl);
42
+ destination.searchParams.set('request', id);
43
+ res.redirect(303, destination.toString());
44
+ }
@@ -14,6 +14,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
14
14
  import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
15
15
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
16
16
  import { createAppilotServer } from '../server.js';
17
+ import { consentLocale } from './consentMessages.js';
18
+ import { renderErrorPage } from './consent.js';
17
19
  import { AppilotOAuthProvider, SUPPORTED_SCOPES } from './oauth.js';
18
20
  /** Where the docs root lands a person who follows the OAuth metadata. */
19
21
  const CONFIGURE_WITH_AI_PATH = '/docs/developers/configure-with-ai/overview';
@@ -24,11 +26,20 @@ export function createRemoteApp(config, options = {}) {
24
26
  new AppilotOAuthProvider({
25
27
  publicUrl: config.publicUrl,
26
28
  baseUrl: config.baseUrl,
29
+ backofficeUrl: config.backofficeUrl,
30
+ handoffSecret: config.handoffSecret,
27
31
  secret: config.secret,
28
32
  });
29
33
  const mcpUrl = new URL('/mcp', config.publicUrl);
30
34
  const app = express();
31
35
  app.disable('x-powered-by');
36
+ app.use(['/authorize', '/consent', '/connect/callback'], (_req, res, next) => {
37
+ res.set('Cache-Control', 'no-store');
38
+ res.set('Referrer-Policy', 'no-referrer');
39
+ res.set('X-Content-Type-Options', 'nosniff');
40
+ res.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' ${config.backofficeUrl ?? ''}; base-uri 'none'; frame-ancestors 'none'`);
41
+ next();
42
+ });
32
43
  // Liveness for the platform. Deliberately says nothing a caller could not
33
44
  // already learn from the instance's own public capabilities endpoint.
34
45
  // Two paths for one probe. Cloud Run's frontend swallows /healthz before it
@@ -49,19 +60,28 @@ export function createRemoteApp(config, options = {}) {
49
60
  scopesSupported: [...SUPPORTED_SCOPES],
50
61
  serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
51
62
  }));
63
+ app.get('/connect/callback', async (req, res) => {
64
+ try {
65
+ const id = typeof req.query.request === 'string' ? req.query.request : '';
66
+ const code = typeof req.query.code === 'string' ? req.query.code : '';
67
+ res.redirect(303, await provider.finishConnection(id, code, req.get('Cookie') ?? '', res));
68
+ }
69
+ catch {
70
+ res.status(400).type('html').send(renderErrorPage('Connection could not be completed', 'The approval expired, was already used, or belongs to another browser. Start again from your assistant.', consentLocale(req.get('Accept-Language'))));
71
+ }
72
+ });
52
73
  // The consent screen posts here. Form-encoded, same-origin, no JSON.
53
74
  app.post('/consent', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
54
75
  try {
55
- const outcome = await provider.handleConsent(req.body);
76
+ const outcome = await provider.handleConsent(req.body, consentLocale(req.get('Accept-Language')));
56
77
  if ('redirect' in outcome) {
57
78
  res.redirect(302, outcome.redirect);
58
79
  return;
59
80
  }
60
81
  res.status(outcome.status).set('Content-Type', 'text/html; charset=utf-8').send(outcome.html);
61
82
  }
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>`);
83
+ catch {
84
+ res.status(500).set('Content-Type', 'text/html; charset=utf-8').send(renderErrorPage('Connection unavailable', 'Appilot could not complete the connection. Please try again.', consentLocale(req.get('Accept-Language'))));
65
85
  }
66
86
  });
67
87
  const requireAuth = requireBearerAuth({
@@ -72,12 +92,15 @@ export function createRemoteApp(config, options = {}) {
72
92
  const auth = req.auth;
73
93
  const extra = (auth?.extra ?? {});
74
94
  // 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.
95
+ // caller's own: the deployment's fixed instance URL, their service token,
96
+ // and the scopes the person actually approved, which the tool surface
97
+ // enforces so the consent screen describes a real limit.
76
98
  const server = createAppilotServer({
77
99
  baseUrl: config.baseUrl,
78
100
  token: extra.pat,
79
101
  defaultAppId: extra.appId,
80
102
  transport: 'http',
103
+ grantedScopes: auth?.scopes ?? [],
81
104
  });
82
105
  const transport = new StreamableHTTPServerTransport({
83
106
  sessionIdGenerator: undefined,