appilot-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +59 -11
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -2
- package/dist/config.d.ts +21 -0
- package/dist/config.js +6 -0
- package/dist/index.bundle.js +1294 -687
- package/dist/index.d.ts +7 -1
- package/dist/index.js +15 -4
- package/dist/redaction.d.ts +51 -0
- package/dist/redaction.js +59 -0
- package/dist/remote/consent.js +1 -0
- package/dist/remote/consentMessages.d.ts +2 -0
- package/dist/remote/consentMessages.js +6 -0
- package/dist/remote/httpServer.d.ts +10 -0
- package/dist/remote/httpServer.js +124 -38
- package/dist/remote/oauth.d.ts +10 -1
- package/dist/remote/oauth.js +20 -2
- package/dist/userClient.d.ts +213 -0
- package/dist/userClient.js +400 -0
- package/dist/userServer.d.ts +47 -0
- package/dist/userServer.js +248 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/mcpb/manifest.json +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Appilot MCP server entry point.
|
|
4
4
|
*
|
|
5
|
-
* Two
|
|
5
|
+
* Two servers ship from this package. Appilot Studio (`server.ts`) writes an
|
|
6
|
+
* app's configuration, and is the default. Appilot (`userServer.ts`, started
|
|
7
|
+
* with `--runtime`) operates an already-configured app for the person using it.
|
|
8
|
+
* One MCP process serves one of them; the remote service serves both at once,
|
|
9
|
+
* on two paths.
|
|
10
|
+
*
|
|
11
|
+
* Two transports (see server.ts):
|
|
6
12
|
*
|
|
7
13
|
* stdio (default) The operator runs the process on their own machine or
|
|
8
14
|
* inside their network, and it carries their credentials in
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Appilot MCP server entry point.
|
|
4
4
|
*
|
|
5
|
-
* Two
|
|
5
|
+
* Two servers ship from this package. Appilot Studio (`server.ts`) writes an
|
|
6
|
+
* app's configuration, and is the default. Appilot (`userServer.ts`, started
|
|
7
|
+
* with `--runtime`) operates an already-configured app for the person using it.
|
|
8
|
+
* One MCP process serves one of them; the remote service serves both at once,
|
|
9
|
+
* on two paths.
|
|
10
|
+
*
|
|
11
|
+
* Two transports (see server.ts):
|
|
6
12
|
*
|
|
7
13
|
* stdio (default) The operator runs the process on their own machine or
|
|
8
14
|
* inside their network, and it carries their credentials in
|
|
@@ -18,15 +24,20 @@
|
|
|
18
24
|
* Server: docs/architecture/appilot-mcp.md.
|
|
19
25
|
*/
|
|
20
26
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
-
import { loadConnection, loadRemoteConfig, resolveTransport, RemoteConfigError } from './config.js';
|
|
27
|
+
import { loadConnection, loadRemoteConfig, resolveServerSurface, resolveTransport, RemoteConfigError } from './config.js';
|
|
22
28
|
import { createAppilotServer } from './server.js';
|
|
29
|
+
import { createAppilotRuntimeServer } from './userServer.js';
|
|
23
30
|
import { runCommand } from './cli.js';
|
|
24
31
|
async function runStdio() {
|
|
25
32
|
const conn = loadConnection();
|
|
26
|
-
const
|
|
33
|
+
const runtime = resolveServerSurface() === 'runtime';
|
|
34
|
+
const server = runtime ? createAppilotRuntimeServer(conn) : createAppilotServer(conn);
|
|
27
35
|
await server.connect(new StdioServerTransport());
|
|
28
36
|
// stderr is safe for logs; stdout is the MCP transport.
|
|
29
|
-
|
|
37
|
+
const credential = runtime
|
|
38
|
+
? `session=${conn.sessionToken ? 'set' : 'none'}`
|
|
39
|
+
: `token=${conn.token ? 'set' : 'none'}`;
|
|
40
|
+
process.stderr.write(`[appilot-mcp] connected · server=${runtime ? 'appilot (runtime)' : 'studio (config)'} · base=${conn.baseUrl ?? 'not-configured'} · ${credential}\n`);
|
|
30
41
|
}
|
|
31
42
|
async function runHttp() {
|
|
32
43
|
const { startRemote } = await import('./remote/httpServer.js');
|
package/dist/redaction.d.ts
CHANGED
|
@@ -16,6 +16,33 @@
|
|
|
16
16
|
* server sees it, and there is no useful way to unsend it. The widget KEY beside
|
|
17
17
|
* the secret is a publishable identifier meant to sit in public HTML, so it
|
|
18
18
|
* travels either way.
|
|
19
|
+
*
|
|
20
|
+
* ## The runtime surface, and the decision it turns on
|
|
21
|
+
*
|
|
22
|
+
* The Appilot connector (`userServer.ts`) reads the page a person is looking at,
|
|
23
|
+
* and the remote transport carries what it reads into a third party's
|
|
24
|
+
* conversation, where it is stored and summarized. That is the hardest call in
|
|
25
|
+
* this module, so the decision and its limit are stated here rather than
|
|
26
|
+
* inferred from the code.
|
|
27
|
+
*
|
|
28
|
+
* The connector cannot filter page content, and that is deliberate. The runtime
|
|
29
|
+
* routes answer a page read with ONE `page_content` string: the DOM tool's
|
|
30
|
+
* result serialized and wrapped in a delimited untrusted-content block. Opening
|
|
31
|
+
* that block to drop a field would mean parsing a hostile string and re-closing
|
|
32
|
+
* a delimiter this package does not own, and the delimiter is the whole
|
|
33
|
+
* prompt-injection defence. So the content passes through verbatim, and what
|
|
34
|
+
* must never leave a page is settled below the bridge instead: `isSensitiveField`
|
|
35
|
+
* closes credential fields inside the responder, which is the single definition
|
|
36
|
+
* and the only one that can act before the value is serialized.
|
|
37
|
+
*
|
|
38
|
+
* What is left to decide here is how much the connector ASKS for, which is the
|
|
39
|
+
* same argument-side lever that refuses a vault credential above. Over the
|
|
40
|
+
* remote transport a page read is capped at `REMOTE_PAGE_TEXT_LIMIT`
|
|
41
|
+
* characters, whether the caller asked for more or asked for nothing at all, so
|
|
42
|
+
* a request for the whole body cannot deposit an entire screen of a customer's
|
|
43
|
+
* data in a conversation nobody can clear. Over stdio the caller's own number
|
|
44
|
+
* stands: the operator is reading their own browser on their own machine, and
|
|
45
|
+
* there is no third party in the path.
|
|
19
46
|
*/
|
|
20
47
|
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.";
|
|
21
48
|
interface ProvisionLike {
|
|
@@ -42,4 +69,28 @@ export declare const CREDENTIAL_REFUSED_NOTICE = "A tool credential (auth_secret
|
|
|
42
69
|
* routes by some other path cannot slip one through.
|
|
43
70
|
*/
|
|
44
71
|
export declare function refuseSecretOverRemote(body: unknown, transport: 'stdio' | 'http' | undefined): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* How much of a page one read may pull across the remote transport.
|
|
74
|
+
*
|
|
75
|
+
* Enough for a banner, an error, a dialog body or a form's worth of state.
|
|
76
|
+
* Not enough for a whole screen of somebody's customer records.
|
|
77
|
+
*/
|
|
78
|
+
export declare const REMOTE_PAGE_TEXT_LIMIT = 4000;
|
|
79
|
+
export declare const PAGE_READ_CAPPED_NOTICE = "Page reads are capped at 4000 characters over the remote transport, because a tool result here is stored in this conversation. Read one region at a time rather than the whole page.";
|
|
80
|
+
export interface PageReadArgs {
|
|
81
|
+
what?: string;
|
|
82
|
+
max_chars?: number;
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Clamp what a page read asks for, and say when the clamp bit.
|
|
87
|
+
*
|
|
88
|
+
* Returns the arguments to send and a note for the caller, which is null when
|
|
89
|
+
* nothing was changed. The `text` read is the only one that takes a character
|
|
90
|
+
* budget; an outline and a form state are bounded by the DOM tools themselves.
|
|
91
|
+
*/
|
|
92
|
+
export declare function clampPageRead(args: PageReadArgs, transport: 'stdio' | 'http' | undefined): {
|
|
93
|
+
args: PageReadArgs;
|
|
94
|
+
note: string | null;
|
|
95
|
+
};
|
|
45
96
|
export {};
|
package/dist/redaction.js
CHANGED
|
@@ -16,6 +16,33 @@
|
|
|
16
16
|
* server sees it, and there is no useful way to unsend it. The widget KEY beside
|
|
17
17
|
* the secret is a publishable identifier meant to sit in public HTML, so it
|
|
18
18
|
* travels either way.
|
|
19
|
+
*
|
|
20
|
+
* ## The runtime surface, and the decision it turns on
|
|
21
|
+
*
|
|
22
|
+
* The Appilot connector (`userServer.ts`) reads the page a person is looking at,
|
|
23
|
+
* and the remote transport carries what it reads into a third party's
|
|
24
|
+
* conversation, where it is stored and summarized. That is the hardest call in
|
|
25
|
+
* this module, so the decision and its limit are stated here rather than
|
|
26
|
+
* inferred from the code.
|
|
27
|
+
*
|
|
28
|
+
* The connector cannot filter page content, and that is deliberate. The runtime
|
|
29
|
+
* routes answer a page read with ONE `page_content` string: the DOM tool's
|
|
30
|
+
* result serialized and wrapped in a delimited untrusted-content block. Opening
|
|
31
|
+
* that block to drop a field would mean parsing a hostile string and re-closing
|
|
32
|
+
* a delimiter this package does not own, and the delimiter is the whole
|
|
33
|
+
* prompt-injection defence. So the content passes through verbatim, and what
|
|
34
|
+
* must never leave a page is settled below the bridge instead: `isSensitiveField`
|
|
35
|
+
* closes credential fields inside the responder, which is the single definition
|
|
36
|
+
* and the only one that can act before the value is serialized.
|
|
37
|
+
*
|
|
38
|
+
* What is left to decide here is how much the connector ASKS for, which is the
|
|
39
|
+
* same argument-side lever that refuses a vault credential above. Over the
|
|
40
|
+
* remote transport a page read is capped at `REMOTE_PAGE_TEXT_LIMIT`
|
|
41
|
+
* characters, whether the caller asked for more or asked for nothing at all, so
|
|
42
|
+
* a request for the whole body cannot deposit an entire screen of a customer's
|
|
43
|
+
* data in a conversation nobody can clear. Over stdio the caller's own number
|
|
44
|
+
* stands: the operator is reading their own browser on their own machine, and
|
|
45
|
+
* there is no third party in the path.
|
|
19
46
|
*/
|
|
20
47
|
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.';
|
|
21
48
|
/**
|
|
@@ -55,3 +82,35 @@ export function refuseSecretOverRemote(body, transport) {
|
|
|
55
82
|
return null;
|
|
56
83
|
return CREDENTIAL_REFUSED_NOTICE;
|
|
57
84
|
}
|
|
85
|
+
// -- the runtime surface -------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* How much of a page one read may pull across the remote transport.
|
|
88
|
+
*
|
|
89
|
+
* Enough for a banner, an error, a dialog body or a form's worth of state.
|
|
90
|
+
* Not enough for a whole screen of somebody's customer records.
|
|
91
|
+
*/
|
|
92
|
+
export const REMOTE_PAGE_TEXT_LIMIT = 4000;
|
|
93
|
+
export const PAGE_READ_CAPPED_NOTICE = `Page reads are capped at ${REMOTE_PAGE_TEXT_LIMIT} characters over the remote transport, because a tool result here is stored in this conversation. Read one region at a time rather than the whole page.`;
|
|
94
|
+
/**
|
|
95
|
+
* Clamp what a page read asks for, and say when the clamp bit.
|
|
96
|
+
*
|
|
97
|
+
* Returns the arguments to send and a note for the caller, which is null when
|
|
98
|
+
* nothing was changed. The `text` read is the only one that takes a character
|
|
99
|
+
* budget; an outline and a form state are bounded by the DOM tools themselves.
|
|
100
|
+
*/
|
|
101
|
+
export function clampPageRead(args, transport) {
|
|
102
|
+
if (transport !== 'http')
|
|
103
|
+
return { args, note: null };
|
|
104
|
+
// Only the `text` read takes a budget. Sending one on an outline or a form
|
|
105
|
+
// state would be a field the route ignores, which reads later as a contract
|
|
106
|
+
// this package believed in.
|
|
107
|
+
if (args.what !== undefined && args.what !== 'text')
|
|
108
|
+
return { args, note: null };
|
|
109
|
+
const asked = typeof args.max_chars === 'number' && Number.isFinite(args.max_chars) ? args.max_chars : null;
|
|
110
|
+
if (asked !== null && asked <= REMOTE_PAGE_TEXT_LIMIT)
|
|
111
|
+
return { args, note: null };
|
|
112
|
+
return {
|
|
113
|
+
args: { ...args, max_chars: REMOTE_PAGE_TEXT_LIMIT },
|
|
114
|
+
note: asked === null ? null : PAGE_READ_CAPPED_NOTICE,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/remote/consent.js
CHANGED
|
@@ -69,6 +69,7 @@ const scopeKeys = {
|
|
|
69
69
|
'config:write': ['write', 'writeHelp'],
|
|
70
70
|
'provision:write': ['provision', 'provisionHelp'],
|
|
71
71
|
'feedback:write': ['feedback', 'feedbackHelp'],
|
|
72
|
+
'runtime:use': ['runtime', 'runtimeHelp'],
|
|
72
73
|
};
|
|
73
74
|
function scopeTitle(scope, locale) {
|
|
74
75
|
const keys = scopeKeys[scope];
|
|
@@ -43,6 +43,8 @@ declare const en: {
|
|
|
43
43
|
readonly provisionHelp: "Create apps, manage domains, and issue widget keys within the token's access.";
|
|
44
44
|
readonly feedback: "Send issue reports to Appilot";
|
|
45
45
|
readonly feedbackHelp: "Share a report title, description, and technical context with Appilot. This sends information outside your organization.";
|
|
46
|
+
readonly runtime: "Use your app through this assistant";
|
|
47
|
+
readonly runtimeHelp: "Read the page you are sharing, answer from your app's knowledge, and run its guided procedures in your own browser. It runs as you, so it can do nothing your account cannot, and it cannot change any configuration.";
|
|
46
48
|
readonly expired: "This sign-in link expired";
|
|
47
49
|
readonly expiredHelp: "Start the connection again from the client that sent you here. A consent link is valid for ten minutes.";
|
|
48
50
|
readonly invalid: "Invalid request";
|
|
@@ -42,6 +42,8 @@ const en = {
|
|
|
42
42
|
provisionHelp: "Create apps, manage domains, and issue widget keys within the token's access.",
|
|
43
43
|
feedback: 'Send issue reports to Appilot',
|
|
44
44
|
feedbackHelp: 'Share a report title, description, and technical context with Appilot. This sends information outside your organization.',
|
|
45
|
+
runtime: 'Use your app through this assistant',
|
|
46
|
+
runtimeHelp: 'Read the page you are sharing, answer from your app\'s knowledge, and run its guided procedures in your own browser. It runs as you, so it can do nothing your account cannot, and it cannot change any configuration.',
|
|
45
47
|
expired: 'This sign-in link expired',
|
|
46
48
|
expiredHelp: 'Start the connection again from the client that sent you here. A consent link is valid for ten minutes.',
|
|
47
49
|
invalid: 'Invalid request',
|
|
@@ -104,6 +106,8 @@ const es = {
|
|
|
104
106
|
provisionHelp: 'Crear aplicaciones, gestionar dominios y emitir claves de widget dentro del acceso del token.',
|
|
105
107
|
feedback: 'Enviar reportes de problemas a Appilot',
|
|
106
108
|
feedbackHelp: 'Compartir un título, una descripción y contexto técnico con Appilot. Envía información fuera de tu organización.',
|
|
109
|
+
runtime: 'Usar tu aplicación desde este asistente',
|
|
110
|
+
runtimeHelp: 'Leer la página que compartes, responder con el conocimiento de tu aplicación y ejecutar sus procedimientos guiados en tu propio navegador. Actúa como tú, así que no puede hacer nada que tu cuenta no pueda, y no puede cambiar ninguna configuración.',
|
|
107
111
|
expired: 'Este enlace de acceso venció',
|
|
108
112
|
expiredHelp: 'Inicia de nuevo la conexión desde el cliente que te envió aquí. El enlace es válido durante diez minutos.',
|
|
109
113
|
invalid: 'Solicitud inválida',
|
|
@@ -163,6 +167,8 @@ const de = {
|
|
|
163
167
|
provisionHelp: 'Anwendungen erstellen, Domains verwalten und Widget-Schlüssel im Zugriffsumfang des Tokens ausstellen.',
|
|
164
168
|
feedback: 'Problemberichte an Appilot senden',
|
|
165
169
|
feedbackHelp: 'Titel, Beschreibung und technischen Kontext mit Appilot teilen. Dabei verlassen Informationen Ihre Organisation.',
|
|
170
|
+
runtime: 'Ihre Anwendung über diesen Assistenten nutzen',
|
|
171
|
+
runtimeHelp: 'Die geteilte Seite lesen, mit dem Wissen Ihrer Anwendung antworten und ihre geführten Abläufe in Ihrem eigenen Browser ausführen. Es handelt als Sie, kann also nichts, was Ihr Konto nicht kann, und keine Konfiguration ändern.',
|
|
166
172
|
expired: 'Dieser Anmeldelink ist abgelaufen',
|
|
167
173
|
expiredHelp: 'Starten Sie die Verbindung erneut im ursprünglichen Client. Der Link ist zehn Minuten gültig.',
|
|
168
174
|
invalid: 'Ungültige Anfrage',
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
|
|
3
3
|
* authorization server that fronts it.
|
|
4
4
|
*
|
|
5
|
+
* One deployment answers for both servers, on two paths behind one authorization
|
|
6
|
+
* server. `/mcp` is Appilot Studio, which writes configuration for a developer.
|
|
7
|
+
* `/mcp/runtime` is Appilot, which operates a configured app for the person
|
|
8
|
+
* using it. They share the consent flow and nothing else: each path refuses a
|
|
9
|
+
* grant that was not approved for it, so a runtime connection cannot reach a
|
|
10
|
+
* Studio tool and a configuration connection cannot reach a runtime tool.
|
|
11
|
+
*
|
|
5
12
|
* Stateless by construction. Each request builds its own MCP server bound to the
|
|
6
13
|
* caller's own service token, which arrives sealed inside the bearer token and
|
|
7
14
|
* never crosses between callers. Nothing is retained between requests, so a
|
|
@@ -12,6 +19,9 @@
|
|
|
12
19
|
import { type Express } from 'express';
|
|
13
20
|
import type { RemoteConfig } from '../config.js';
|
|
14
21
|
import { AppilotOAuthProvider } from './oauth.js';
|
|
22
|
+
/** Where each server answers. One deployment, two MCP resources, one authorization server. */
|
|
23
|
+
export declare const STUDIO_MCP_PATH = "/mcp";
|
|
24
|
+
export declare const RUNTIME_MCP_PATH = "/mcp/runtime";
|
|
15
25
|
export interface RemoteAppOptions {
|
|
16
26
|
/** Swappable in tests so no real instance is contacted. */
|
|
17
27
|
provider?: AppilotOAuthProvider;
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* The remote Appilot MCP service: Streamable HTTP transport plus the OAuth
|
|
3
3
|
* authorization server that fronts it.
|
|
4
4
|
*
|
|
5
|
+
* One deployment answers for both servers, on two paths behind one authorization
|
|
6
|
+
* server. `/mcp` is Appilot Studio, which writes configuration for a developer.
|
|
7
|
+
* `/mcp/runtime` is Appilot, which operates a configured app for the person
|
|
8
|
+
* using it. They share the consent flow and nothing else: each path refuses a
|
|
9
|
+
* grant that was not approved for it, so a runtime connection cannot reach a
|
|
10
|
+
* Studio tool and a configuration connection cannot reach a runtime tool.
|
|
11
|
+
*
|
|
5
12
|
* Stateless by construction. Each request builds its own MCP server bound to the
|
|
6
13
|
* caller's own service token, which arrives sealed inside the bearer token and
|
|
7
14
|
* never crosses between callers. Nothing is retained between requests, so a
|
|
@@ -11,16 +18,20 @@
|
|
|
11
18
|
*/
|
|
12
19
|
import express from 'express';
|
|
13
20
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
-
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
21
|
+
import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
15
22
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
16
23
|
import { createAppilotServer } from '../server.js';
|
|
24
|
+
import { createAppilotRuntimeServer, RUNTIME_SCOPE, RUNTIME_SCOPE_HELP } from '../userServer.js';
|
|
17
25
|
import { consentLocale } from './consentMessages.js';
|
|
18
26
|
import { renderErrorPage } from './consent.js';
|
|
19
|
-
import { AppilotOAuthProvider, SUPPORTED_SCOPES } from './oauth.js';
|
|
27
|
+
import { AppilotOAuthProvider, SERVICE_TOKEN_SCOPES, SUPPORTED_SCOPES } from './oauth.js';
|
|
20
28
|
/** Where the docs root lands a person who follows the OAuth metadata. */
|
|
21
29
|
const CONFIGURE_WITH_AI_PATH = '/docs/developers/configure-with-ai/overview';
|
|
22
30
|
/** Body cap for a JSON-RPC request. A ConfigBundle import is the large one. */
|
|
23
31
|
const MAX_BODY = '32mb';
|
|
32
|
+
/** Where each server answers. One deployment, two MCP resources, one authorization server. */
|
|
33
|
+
export const STUDIO_MCP_PATH = '/mcp';
|
|
34
|
+
export const RUNTIME_MCP_PATH = '/mcp/runtime';
|
|
24
35
|
export function createRemoteApp(config, options = {}) {
|
|
25
36
|
const provider = options.provider ??
|
|
26
37
|
new AppilotOAuthProvider({
|
|
@@ -30,7 +41,8 @@ export function createRemoteApp(config, options = {}) {
|
|
|
30
41
|
handoffSecret: config.handoffSecret,
|
|
31
42
|
secret: config.secret,
|
|
32
43
|
});
|
|
33
|
-
const mcpUrl = new URL(
|
|
44
|
+
const mcpUrl = new URL(STUDIO_MCP_PATH, config.publicUrl);
|
|
45
|
+
const runtimeMcpUrl = new URL(RUNTIME_MCP_PATH, config.publicUrl);
|
|
34
46
|
const app = express();
|
|
35
47
|
app.disable('x-powered-by');
|
|
36
48
|
app.use(['/authorize', '/consent', '/connect/callback'], (_req, res, next) => {
|
|
@@ -49,6 +61,26 @@ export function createRemoteApp(config, options = {}) {
|
|
|
49
61
|
app.get(['/health', '/healthz'], (_req, res) => {
|
|
50
62
|
res.json({ status: 'ok', transport: 'http', instance: config.baseUrl });
|
|
51
63
|
});
|
|
64
|
+
// The runtime resource's own metadata, mounted first.
|
|
65
|
+
//
|
|
66
|
+
// RFC 9728 puts a resource's metadata at a path-specific well-known URL, and
|
|
67
|
+
// the SDK mounts each one with a prefix match. `/…/mcp` therefore also matches
|
|
68
|
+
// `/…/mcp/runtime`, so the more specific resource has to be registered ahead
|
|
69
|
+
// of the more general one or a runtime client discovers the Studio resource
|
|
70
|
+
// and asks for the wrong scope.
|
|
71
|
+
app.use(mcpAuthMetadataRouter({
|
|
72
|
+
oauthMetadata: createOAuthMetadata({
|
|
73
|
+
provider,
|
|
74
|
+
issuerUrl: config.publicUrl,
|
|
75
|
+
baseUrl: config.publicUrl,
|
|
76
|
+
scopesSupported: [...SUPPORTED_SCOPES],
|
|
77
|
+
serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
|
|
78
|
+
}),
|
|
79
|
+
resourceServerUrl: runtimeMcpUrl,
|
|
80
|
+
resourceName: 'Appilot',
|
|
81
|
+
scopesSupported: [RUNTIME_SCOPE],
|
|
82
|
+
serviceDocumentationUrl: new URL(CONFIGURE_WITH_AI_PATH, config.docsUrl),
|
|
83
|
+
}));
|
|
52
84
|
// /.well-known/oauth-authorization-server, /authorize, /token, /register.
|
|
53
85
|
// Must be mounted at the application root.
|
|
54
86
|
app.use(mcpAuthRouter({
|
|
@@ -88,43 +120,95 @@ export function createRemoteApp(config, options = {}) {
|
|
|
88
120
|
verifier: provider,
|
|
89
121
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
|
90
122
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
const requireRuntimeAuth = requireBearerAuth({
|
|
124
|
+
verifier: provider,
|
|
125
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(runtimeMcpUrl),
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* Keep the two servers apart at the door.
|
|
129
|
+
*
|
|
130
|
+
* The tools enforce their own scopes one layer in, and that is not enough on
|
|
131
|
+
* its own: several Studio tools are pure and need no scope, so a runtime-only
|
|
132
|
+
* grant could still reach them if the endpoint let it through. The endpoint
|
|
133
|
+
* therefore decides first. Studio needs at least one configuration scope,
|
|
134
|
+
* Appilot needs `runtime:use`, and a grant approved for both reaches both,
|
|
135
|
+
* which is what approving both means.
|
|
136
|
+
*/
|
|
137
|
+
function wrongServer(scopes, wanted) {
|
|
138
|
+
const granted = scopes ?? [];
|
|
139
|
+
const reaches = wanted === 'runtime'
|
|
140
|
+
? granted.includes(RUNTIME_SCOPE)
|
|
141
|
+
: granted.some(scope => SERVICE_TOKEN_SCOPES.includes(scope));
|
|
142
|
+
if (reaches)
|
|
143
|
+
return null;
|
|
144
|
+
const held = granted.length ? granted.join(', ') : 'no scopes';
|
|
145
|
+
return wanted === 'runtime'
|
|
146
|
+
? `This connection was approved for ${held}, which does not include ${RUNTIME_SCOPE}, so it reaches Appilot Studio at ${STUDIO_MCP_PATH} and not the Appilot connector. ${RUNTIME_SCOPE_HELP}`
|
|
147
|
+
: `This connection was approved for ${held}, which carries no configuration scope, so it reaches the Appilot connector at ${RUNTIME_MCP_PATH} and not Appilot Studio. Studio is a separate connection: add it in your assistant and approve it with a service token that carries config:read or config:write, which an organization administrator mints in the Backoffice under Service tokens.`;
|
|
148
|
+
}
|
|
149
|
+
function mcpEndpoint(surface) {
|
|
150
|
+
return async (req, res) => {
|
|
151
|
+
const auth = req.auth;
|
|
152
|
+
const extra = (auth?.extra ?? {});
|
|
153
|
+
const refusal = wrongServer(auth?.scopes, surface);
|
|
154
|
+
if (refusal) {
|
|
155
|
+
res.status(403).json({
|
|
121
156
|
jsonrpc: '2.0',
|
|
122
|
-
error: { code: -
|
|
157
|
+
error: { code: -32001, message: refusal },
|
|
123
158
|
id: null,
|
|
124
159
|
});
|
|
160
|
+
return;
|
|
125
161
|
}
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
// One server and one transport per request. The connection profile is the
|
|
163
|
+
// caller's own: the deployment's fixed instance URL, the credential the
|
|
164
|
+
// approval sealed, and the scopes the person actually approved, which the
|
|
165
|
+
// tool surface enforces so the consent screen describes a real limit.
|
|
166
|
+
//
|
|
167
|
+
// The sealed credential is a service token for Studio and the person's
|
|
168
|
+
// own Appilot session for the connector. It is read here, never echoed,
|
|
169
|
+
// and the two never reach the same server.
|
|
170
|
+
const server = surface === 'runtime'
|
|
171
|
+
? createAppilotRuntimeServer({
|
|
172
|
+
baseUrl: config.baseUrl,
|
|
173
|
+
sessionToken: extra.pat,
|
|
174
|
+
transport: 'http',
|
|
175
|
+
grantedScopes: auth?.scopes ?? [],
|
|
176
|
+
})
|
|
177
|
+
: createAppilotServer({
|
|
178
|
+
baseUrl: config.baseUrl,
|
|
179
|
+
token: extra.pat,
|
|
180
|
+
defaultAppId: extra.appId,
|
|
181
|
+
transport: 'http',
|
|
182
|
+
grantedScopes: auth?.scopes ?? [],
|
|
183
|
+
});
|
|
184
|
+
const transport = new StreamableHTTPServerTransport({
|
|
185
|
+
sessionIdGenerator: undefined,
|
|
186
|
+
enableDnsRebindingProtection: true,
|
|
187
|
+
allowedHosts: config.allowedHosts,
|
|
188
|
+
});
|
|
189
|
+
res.on('close', () => {
|
|
190
|
+
void transport.close();
|
|
191
|
+
void server.close();
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
await server.connect(transport);
|
|
195
|
+
await transport.handleRequest(req, res, req.body);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (!res.headersSent) {
|
|
199
|
+
res.status(500).json({
|
|
200
|
+
jsonrpc: '2.0',
|
|
201
|
+
error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' },
|
|
202
|
+
id: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// The runtime path is registered first: express matches in order, and `/mcp`
|
|
209
|
+
// is a prefix of it in every other place this file touches.
|
|
210
|
+
app.post(RUNTIME_MCP_PATH, requireRuntimeAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('runtime'));
|
|
211
|
+
app.post(STUDIO_MCP_PATH, requireAuth, express.json({ limit: MAX_BODY }), mcpEndpoint('studio'));
|
|
128
212
|
// Stateless: there is no stream to resume and no session to delete.
|
|
129
213
|
const methodNotAllowed = (_req, res) => {
|
|
130
214
|
res.status(405).set('Allow', 'POST').json({
|
|
@@ -133,8 +217,10 @@ export function createRemoteApp(config, options = {}) {
|
|
|
133
217
|
id: null,
|
|
134
218
|
});
|
|
135
219
|
};
|
|
136
|
-
|
|
137
|
-
|
|
220
|
+
for (const path of [STUDIO_MCP_PATH, RUNTIME_MCP_PATH]) {
|
|
221
|
+
app.get(path, methodNotAllowed);
|
|
222
|
+
app.delete(path, methodNotAllowed);
|
|
223
|
+
}
|
|
138
224
|
return app;
|
|
139
225
|
}
|
|
140
226
|
export function startRemote(config) {
|
package/dist/remote/oauth.d.ts
CHANGED
|
@@ -34,8 +34,17 @@ import { type ConsentLocale } from './consentMessages.js';
|
|
|
34
34
|
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
35
35
|
* tenant, which is why it is separate from the config scopes rather than folded
|
|
36
36
|
* into them.
|
|
37
|
+
*
|
|
38
|
+
* `runtime:use` is the odd one and it is the reason this list is not simply the
|
|
39
|
+
* service-token scopes. It authorizes the Appilot connector, which holds a
|
|
40
|
+
* person's own session and operates their app through the page they are looking
|
|
41
|
+
* at. It grants nothing over configuration, and the four config scopes grant
|
|
42
|
+
* nothing over the runtime surface: the two servers sit on separate paths, each
|
|
43
|
+
* refusing a grant that was not approved for it (see `remote/httpServer.ts`).
|
|
37
44
|
*/
|
|
38
|
-
export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write"];
|
|
45
|
+
export declare const SUPPORTED_SCOPES: readonly ["config:read", "config:write", "provision:write", "feedback:write", "runtime:use"];
|
|
46
|
+
/** The scopes a service token can carry. `runtime:use` is not one of them. */
|
|
47
|
+
export declare const SERVICE_TOKEN_SCOPES: readonly string[];
|
|
39
48
|
/** What the instance says a credential reaches. Absent on instances without /config/whoami. */
|
|
40
49
|
export interface TokenIdentity {
|
|
41
50
|
organizationName?: string | null;
|
package/dist/remote/oauth.js
CHANGED
|
@@ -35,12 +35,27 @@ import { renderConsentPage, renderConfirmPage, renderErrorPage } from './consent
|
|
|
35
35
|
* `feedback:write` is the one scope that governs sending data OUT of the
|
|
36
36
|
* tenant, which is why it is separate from the config scopes rather than folded
|
|
37
37
|
* into them.
|
|
38
|
+
*
|
|
39
|
+
* `runtime:use` is the odd one and it is the reason this list is not simply the
|
|
40
|
+
* service-token scopes. It authorizes the Appilot connector, which holds a
|
|
41
|
+
* person's own session and operates their app through the page they are looking
|
|
42
|
+
* at. It grants nothing over configuration, and the four config scopes grant
|
|
43
|
+
* nothing over the runtime surface: the two servers sit on separate paths, each
|
|
44
|
+
* refusing a grant that was not approved for it (see `remote/httpServer.ts`).
|
|
38
45
|
*/
|
|
39
46
|
export const SUPPORTED_SCOPES = [
|
|
40
47
|
'config:read',
|
|
41
48
|
'config:write',
|
|
42
49
|
'provision:write',
|
|
43
50
|
'feedback:write',
|
|
51
|
+
'runtime:use',
|
|
52
|
+
];
|
|
53
|
+
/** The scopes a service token can carry. `runtime:use` is not one of them. */
|
|
54
|
+
export const SERVICE_TOKEN_SCOPES = [
|
|
55
|
+
'config:read',
|
|
56
|
+
'config:write',
|
|
57
|
+
'provision:write',
|
|
58
|
+
'feedback:write',
|
|
44
59
|
];
|
|
45
60
|
/**
|
|
46
61
|
* What to ask for when the client asks for nothing.
|
|
@@ -112,8 +127,11 @@ async function defaultVerifyServiceToken(baseUrl, pat) {
|
|
|
112
127
|
if (res.status === 401 || res.status === 403)
|
|
113
128
|
return null;
|
|
114
129
|
if (res.status === 404) {
|
|
115
|
-
// Instance predates the self-check. Accept the token on shape alone
|
|
116
|
-
|
|
130
|
+
// Instance predates the self-check. Accept the token on shape alone, and
|
|
131
|
+
// only for what a service token can ever carry: `runtime:use` names a
|
|
132
|
+
// person's session, so an instance that cannot answer must not be read as
|
|
133
|
+
// having said yes to it.
|
|
134
|
+
return { organizationId: null, appId: null, scopes: [...SERVICE_TOKEN_SCOPES] };
|
|
117
135
|
}
|
|
118
136
|
if (!res.ok) {
|
|
119
137
|
throw new ServerError(`The Appilot instance answered ${res.status} while verifying the service token.`);
|