@faable/faable 4.5.5 → 4.6.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/dist/mcp/http.js CHANGED
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { version } from '../config.js';
8
+ import { ALL_SCOPES, AUTH_ISSUER, oauth_enabled, protected_resource_metadata, www_authenticate, looks_like_jwt, verify_access_token, resource_of, permissions_of, permission_of, delegated_token_for, prm_url } from './oauth.js';
8
9
  import { run_cli } from './run_cli.js';
9
10
  import { tools_for, create_server } from './server.js';
10
11
  import { TOOLS } from './tools.js';
@@ -40,8 +41,17 @@ const api_key_of = (req) => {
40
41
  if (!key && basic) {
41
42
  key = Buffer.from(basic[1], 'base64').toString('utf8').split(':')[0];
42
43
  }
43
- return key && /^[\w.~+/=-]{8,256}$/.test(key) ? key : null;
44
+ if (!key)
45
+ return null;
46
+ // An OAuth access token (a JWT) is longer than any API key.
47
+ if (looks_like_jwt(key))
48
+ return key.length <= 8192 ? key : null;
49
+ return /^[\w.~+/=-]{8,256}$/.test(key) ? key : null;
44
50
  };
51
+ // The tools a JSON-RPC body calls (one message or a batch).
52
+ const calls_in = (body) => (Array.isArray(body) ? body : [body])
53
+ .filter((m) => m?.method === 'tools/call' && typeof m?.params?.name === 'string')
54
+ .map((m) => m.params.name);
45
55
  // /mcp → {}, /mcp/<project> → { project }; anything else → null.
46
56
  const route_of = (pathname) => {
47
57
  const m = /^\/mcp(?:\/([^/]+))?\/?$/.exec(pathname);
@@ -52,13 +62,20 @@ const route_of = (pathname) => {
52
62
  // The environment of one call: the server's own, minus every credential and
53
63
  // context it might carry, plus the caller's key and an empty HOME.
54
64
  const SCRUBBED = /^(FAABLE_(TOKEN|API_KEY|PROJECT|AUTH_ACCOUNT|AUTH_URL|ID_TOKEN)|GITHUB_ACTIONS|ACTIONS_ID_TOKEN_.*)$/;
55
- const child_env = (key, home) => {
65
+ const child_env = (cred, home) => {
56
66
  const env = {};
57
67
  for (const [k, v] of Object.entries(process.env)) {
58
68
  if (!SCRUBBED.test(k))
59
69
  env[k] = v;
60
70
  }
61
- return { ...env, HOME: home, FAABLE_API_KEY: key };
71
+ const c = typeof cred === 'string' ? { api_key: cred } : cred;
72
+ return {
73
+ ...env,
74
+ HOME: home,
75
+ ...('api_key' in c
76
+ ? { FAABLE_API_KEY: c.api_key }
77
+ : { FAABLE_TOKEN: c.token })
78
+ };
62
79
  };
63
80
  // At most `max` children at once.
64
81
  const limiter = (max) => {
@@ -77,13 +94,16 @@ const limiter = (max) => {
77
94
  }
78
95
  };
79
96
  };
80
- const runner_for = (key, limit, entry) => (argv, opts) => limit(async () => {
97
+ const runner_for = (credential, limit, entry) => (argv, opts) => limit(async () => {
98
+ // Resolved per call, not per request: an OAuth connection trades its
99
+ // token only when a tool actually runs, never for a tools/list.
100
+ const cred = await credential();
81
101
  const home = await mkdtemp(path.join(os.tmpdir(), 'faable-mcp-'));
82
102
  try {
83
103
  return await run_cli(argv, {
84
104
  ...opts,
85
105
  entry,
86
- env: child_env(key, home)
106
+ env: child_env(cred, home)
87
107
  });
88
108
  }
89
109
  finally {
@@ -131,11 +151,24 @@ const catalog = (base = PUBLIC_URL) => ({
131
151
  version,
132
152
  endpoint: `${base}/mcp`,
133
153
  transport: 'streamable-http',
134
- auth: {
135
- type: 'api-key',
136
- header: 'Authorization: Bearer <key>',
137
- keys: KEYS_URL
138
- },
154
+ // OAuth first when the server has it on (oauth.ts); an API key always works.
155
+ auth: oauth_enabled()
156
+ ? {
157
+ type: 'oauth',
158
+ authorization_server: AUTH_ISSUER,
159
+ protected_resource_metadata: prm_url(base),
160
+ scopes: ALL_SCOPES,
161
+ alternative: {
162
+ type: 'api-key',
163
+ header: 'Authorization: Bearer <key>',
164
+ keys: KEYS_URL
165
+ }
166
+ }
167
+ : {
168
+ type: 'api-key',
169
+ header: 'Authorization: Bearer <key>',
170
+ keys: KEYS_URL
171
+ },
139
172
  docs: DOCS_URL,
140
173
  tools: TOOLS.map(t => ({
141
174
  name: t.name,
@@ -151,7 +184,9 @@ const llms_txt = (base = PUBLIC_URL) => [
151
184
  '',
152
185
  '> Connects Claude, Cursor and any MCP client to Faable Deploy: read apps, deployments, build and runtime logs, traffic and domains, and deploy — without leaving the editor.',
153
186
  '',
154
- `Endpoint: ${base}/mcp (Streamable HTTP). Authenticate with a Faable API key: \`Authorization: Bearer <key>\` (create one in the dashboard, project settings → API keys). The key belongs to one project and only acts there.`,
187
+ oauth_enabled()
188
+ ? `Endpoint: ${base}/mcp (Streamable HTTP). Sign in with OAuth: an MCP client discovers it from the 401 (\`resource_metadata\`), opens a Faable sign-in and consent screen where the user picks one project and what the agent may do (\`deploy:read\`, \`deploy:deploy\`; \`deploy:write\` is asked for the first time a write tool is called). Or send a Faable API key: \`Authorization: Bearer <key>\` (project settings → API keys). Either way it acts on one project only.`
189
+ : `Endpoint: ${base}/mcp (Streamable HTTP). Authenticate with a Faable API key: \`Authorization: Bearer <key>\` (create one in the dashboard, project settings → API keys). The key belongs to one project and only acts there.`,
155
190
  '',
156
191
  `- \`${base}/mcp/<project>\` pins every call to one project.`,
157
192
  '- `?mode=write` adds the reversible writes (create apps, set secrets, add domains, retry or cancel builds, change deploy settings). Nothing destructive is exposed.',
@@ -197,6 +232,14 @@ const create_http_server = (opts) => {
197
232
  if (req.method === 'GET' && url.pathname === '/llms.txt') {
198
233
  return send(res, 200, llms_txt(base), 'text/plain; charset=utf-8');
199
234
  }
235
+ // RFC 9728: at the root and with the resource path inserted (what MCP
236
+ // clients derive from https://mcp.faable.com/mcp). Only with OAuth on.
237
+ if (req.method === 'GET' &&
238
+ /^\/\.well-known\/oauth-protected-resource(\/mcp(\/[^/]+)?)?\/?$/.test(url.pathname)) {
239
+ if (!oauth_enabled())
240
+ return send(res, 404, { error: 'not found' });
241
+ return send(res, 200, protected_resource_metadata(base));
242
+ }
200
243
  const route = route_of(url.pathname);
201
244
  if (!route)
202
245
  return send(res, 404, { error: 'not found', docs: DOCS_URL });
@@ -206,11 +249,32 @@ const create_http_server = (opts) => {
206
249
  return send(res, 405, rpc_error(-32000, 'Method not allowed: this server is stateless, use POST.'));
207
250
  }
208
251
  const key = api_key_of(req);
209
- if (!key) {
210
- res.setHeader('www-authenticate', 'Bearer realm="faable"');
252
+ const unauthorized = (message, error) => {
253
+ res.setHeader('www-authenticate', oauth_enabled()
254
+ ? www_authenticate(base, { error })
255
+ : 'Bearer realm="faable"');
211
256
  status = 401;
212
- return send(res, 401, rpc_error(-32001, `Send a Faable API key: "Authorization: Bearer <key>". Create one in the dashboard (project settings → API keys): ${KEYS_URL}`));
257
+ return send(res, 401, rpc_error(-32001, message));
258
+ };
259
+ if (!key) {
260
+ return unauthorized(oauth_enabled()
261
+ ? `Connect with your Faable account (your MCP client opens the sign-in), or send a Faable API key: "Authorization: Bearer <key>" (${KEYS_URL}).`
262
+ : `Send a Faable API key: "Authorization: Bearer <key>". Create one in the dashboard (project settings → API keys): ${KEYS_URL}`);
213
263
  }
264
+ // A JWT is an OAuth access token (Fase 3); anything else, an API key.
265
+ let claims;
266
+ if (looks_like_jwt(key)) {
267
+ if (!oauth_enabled()) {
268
+ return unauthorized(`This server does not accept OAuth tokens yet. Send a Faable API key: ${KEYS_URL}`);
269
+ }
270
+ try {
271
+ claims = await verify_access_token(key, resource_of(base));
272
+ }
273
+ catch (e) {
274
+ return unauthorized(`Invalid access token: ${e.message}. Reconnect.`, 'invalid_token');
275
+ }
276
+ }
277
+ const granted = claims ? permissions_of(claims) : undefined;
214
278
  let body;
215
279
  try {
216
280
  body = await read_body(req);
@@ -218,11 +282,39 @@ const create_http_server = (opts) => {
218
282
  catch (e) {
219
283
  return send(res, 400, rpc_error(-32700, e.message));
220
284
  }
285
+ // Step-up (MCP authorization): a tool this connection was not allowed
286
+ // answers 403 insufficient_scope with the scope it needs, and the
287
+ // client asks the user for it on the consent screen.
288
+ if (granted) {
289
+ const needed = calls_in(body)
290
+ .map(name => TOOLS.find(t => t.name === name))
291
+ .filter((t) => !!t)
292
+ .map(permission_of)
293
+ .filter(p => !granted.includes(p));
294
+ if (needed.length) {
295
+ res.setHeader('www-authenticate', www_authenticate(base, {
296
+ error: 'insufficient_scope',
297
+ scope: [...new Set([...granted, ...needed])]
298
+ }));
299
+ status = 403;
300
+ return send(res, 403, rpc_error(-32001, `This connection was not allowed to ${needed.join(', ')}. Your MCP client will ask you to allow it.`));
301
+ }
302
+ }
303
+ const writes = url.searchParams.get('mode') === 'write';
221
304
  const server = create_server({
222
- writes: url.searchParams.get('mode') === 'write',
305
+ writes,
223
306
  readonly: ['1', 'true'].includes(url.searchParams.get('readonly') ?? ''),
224
- project: route.project,
225
- run: runner_for(key, limit, opts.entry)
307
+ // An OAuth connection acts on the project the user picked on the
308
+ // consent screen, whatever the path or the agent say.
309
+ project: claims?.project ?? route.project,
310
+ // Its tools are the ones its permissions allow — plus, with
311
+ // ?mode=write, the writes it may ask for by step-up.
312
+ allow: granted
313
+ ? t => granted.includes(permission_of(t)) || (writes && !!t.write)
314
+ : undefined,
315
+ run: runner_for(claims
316
+ ? async () => ({ token: await delegated_token_for(key) })
317
+ : async () => ({ api_key: key }), limit, opts.entry)
226
318
  });
227
319
  const transport = new StreamableHTTPServerTransport({
228
320
  sessionIdGenerator: undefined,
@@ -255,4 +347,4 @@ const serve_http = async (opts) => {
255
347
  return server;
256
348
  };
257
349
 
258
- export { PUBLIC_URL, api_key_of, catalog, child_env, create_http_server, llms_txt, route_of, serve_http };
350
+ export { PUBLIC_URL, api_key_of, calls_in, catalog, child_env, create_http_server, llms_txt, route_of, serve_http };
@@ -0,0 +1,161 @@
1
+ import { verify, createPublicKey, createHash } from 'node:crypto';
2
+
3
+ // OAuth on mcp.faable.com (Fase 3 of arch/deploy/mcp-server-faable.md; plan
4
+ // in arch/auth/mcp-oauth.md 3.5). The MCP server is a resource server:
5
+ //
6
+ // 1. the MCP client gets a token from Faable Auth for THIS resource
7
+ // (aud = https://mcp.faable.com/mcp) after the user allowed it on the
8
+ // consent screen — permissions and one project;
9
+ // 2. this server checks it (issuer, audience, expiry, signature) and shows
10
+ // only the tools its permissions allow;
11
+ // 3. for a tool call it trades it at Faable Auth (RFC 8693) for a short
12
+ // token of its own, `act` = this server, and the CLI child calls the
13
+ // api with that. The client's token is never passed on (MCP spec:
14
+ // MUST NOT), and the api refuses it if anyone tries.
15
+ //
16
+ // Off unless the server has its own client credentials
17
+ // (FAABLE_MCP_CLIENT_ID / FAABLE_MCP_CLIENT_SECRET): advertising OAuth before
18
+ // Faable Auth accepts MCP clients would send them into a flow that fails.
19
+ // API keys keep working either way.
20
+ const AUTH_ISSUER = process.env.FAABLE_MCP_AUTH_ISSUER ?? 'https://faable.auth.faable.link';
21
+ const oauth_enabled = () => !!process.env.FAABLE_MCP_CLIENT_ID && !!process.env.FAABLE_MCP_CLIENT_SECRET;
22
+ // What the consent screen asks for on a first connection (Marc, 05-10:
23
+ // reads plus deploy, like the hosted server by default). deploy:write comes
24
+ // by step-up, the first time an agent calls a tool that needs it.
25
+ const DEFAULT_SCOPES = ['deploy:read', 'deploy:deploy'];
26
+ const ALL_SCOPES = [
27
+ 'deploy:read',
28
+ 'deploy:deploy',
29
+ 'deploy:write'
30
+ ];
31
+ const DEPLOY_TOOLS = new Set(['deploy_app', 'redeploy', 'cancel_deployment']);
32
+ /** The permission a tool needs — the same split the api enforces. */
33
+ const permission_of = (tool) => DEPLOY_TOOLS.has(tool.name)
34
+ ? 'deploy:deploy'
35
+ : tool.write
36
+ ? 'deploy:write'
37
+ : 'deploy:read';
38
+ const resource_of = (base) => `${base}/mcp`;
39
+ /** RFC 9728 Protected Resource Metadata. */
40
+ const protected_resource_metadata = (base) => ({
41
+ resource: resource_of(base),
42
+ authorization_servers: [AUTH_ISSUER],
43
+ scopes_supported: ALL_SCOPES,
44
+ bearer_methods_supported: ['header'],
45
+ resource_name: 'Faable',
46
+ resource_documentation: 'https://faable.com/mcp'
47
+ });
48
+ const prm_url = (base) => `${base}/.well-known/oauth-protected-resource/mcp`;
49
+ /** The `WWW-Authenticate` of a 401/403 (RFC 6750 §3, MCP authorization). */
50
+ const www_authenticate = (base, extra = {}) => [
51
+ 'Bearer realm="faable"',
52
+ `resource_metadata="${prm_url(base)}"`,
53
+ `scope="${(extra.scope ?? DEFAULT_SCOPES).join(' ')}"`,
54
+ ...(extra.error ? [`error="${extra.error}"`] : [])
55
+ ].join(', ');
56
+ const looks_like_jwt = (token) => /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
57
+ let jwks_cache = null;
58
+ const JWKS_TTL_MS = 10 * 60 * 1000;
59
+ let http = (input, init) => fetch(input, init);
60
+ const jwk_for = async (kid) => {
61
+ const pick = (keys) => keys.find(k => !kid || k.kid === kid);
62
+ if (jwks_cache && Date.now() - jwks_cache.at < JWKS_TTL_MS) {
63
+ const hit = pick(jwks_cache.keys);
64
+ if (hit)
65
+ return hit;
66
+ }
67
+ // A kid we do not know yet may be a rotation: fetch once more.
68
+ const res = await http(`${AUTH_ISSUER}/.well-known/jwks.json`);
69
+ if (!res.ok)
70
+ throw new Error(`jwks: HTTP ${res.status}`);
71
+ const body = (await res.json());
72
+ jwks_cache = { keys: body.keys ?? [], at: Date.now() };
73
+ return pick(jwks_cache.keys);
74
+ };
75
+ const b64json = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
76
+ class InvalidToken extends Error {
77
+ }
78
+ /** The claims of a valid token for this resource, or InvalidToken. */
79
+ const verify_access_token = async (token, resource) => {
80
+ const [h, p, s] = token.split('.');
81
+ let header;
82
+ let claims;
83
+ try {
84
+ header = b64json(h);
85
+ claims = b64json(p);
86
+ }
87
+ catch {
88
+ throw new InvalidToken('malformed token');
89
+ }
90
+ if (header.alg !== 'RS256')
91
+ throw new InvalidToken('unexpected algorithm');
92
+ if (claims.iss !== AUTH_ISSUER)
93
+ throw new InvalidToken('unexpected issuer');
94
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
95
+ // RFC 8707 / MCP: a token for another resource is not ours to accept.
96
+ if (!aud.includes(resource))
97
+ throw new InvalidToken('token is for another resource');
98
+ if (!claims.exp || claims.exp * 1000 < Date.now())
99
+ throw new InvalidToken('token expired');
100
+ const jwk = await jwk_for(header.kid);
101
+ if (!jwk)
102
+ throw new InvalidToken('unknown signing key');
103
+ const ok = verify('RSA-SHA256', Buffer.from(`${h}.${p}`), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(s, 'base64url'));
104
+ if (!ok)
105
+ throw new InvalidToken('bad signature');
106
+ if (!claims.sub)
107
+ throw new InvalidToken('token has no subject');
108
+ return claims;
109
+ };
110
+ const permissions_of = (claims) => String(claims.permissions ?? '')
111
+ .split(' ')
112
+ .filter((p) => ALL_SCOPES.includes(p));
113
+ // ── Trading it for our own (RFC 8693) ─────────────────────────────────────
114
+ const exchange_cache = new Map();
115
+ // Shorter than the delegated token's own life, so a cached one never expires
116
+ // mid-call, and short enough that a disconnected app stops within a minute.
117
+ const EXCHANGE_CACHE_MS = 60 * 1000;
118
+ class ExchangeRefused extends Error {
119
+ status;
120
+ constructor(message, status) {
121
+ super(message);
122
+ this.status = status;
123
+ }
124
+ }
125
+ const delegated_token_for = async (subject) => {
126
+ const key = createHash('sha256').update(subject).digest('base64url');
127
+ const hit = exchange_cache.get(key);
128
+ if (hit && hit.until > Date.now())
129
+ return hit.token;
130
+ const res = await http(`${AUTH_ISSUER}/oauth/token`, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'content-type': 'application/x-www-form-urlencoded',
134
+ 'x-faable-client': 'faable-mcp'
135
+ },
136
+ body: new URLSearchParams({
137
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
138
+ client_id: process.env.FAABLE_MCP_CLIENT_ID ?? '',
139
+ client_secret: process.env.FAABLE_MCP_CLIENT_SECRET ?? '',
140
+ subject_token: subject,
141
+ subject_token_type: 'urn:ietf:params:oauth:token-type:access_token'
142
+ }).toString()
143
+ });
144
+ const body = (await res.json().catch(() => ({})));
145
+ if (!res.ok || !body.access_token) {
146
+ throw new ExchangeRefused(body.error_description ?? `token exchange: HTTP ${res.status}`, res.status);
147
+ }
148
+ exchange_cache.set(key, {
149
+ token: body.access_token,
150
+ until: Date.now() + EXCHANGE_CACHE_MS
151
+ });
152
+ // Bounded: one entry per live connection, swept as they expire.
153
+ if (exchange_cache.size > 5000) {
154
+ for (const [k, v] of exchange_cache)
155
+ if (v.until <= Date.now())
156
+ exchange_cache.delete(k);
157
+ }
158
+ return body.access_token;
159
+ };
160
+
161
+ export { ALL_SCOPES, AUTH_ISSUER, DEFAULT_SCOPES, ExchangeRefused, InvalidToken, delegated_token_for, looks_like_jwt, oauth_enabled, permission_of, permissions_of, prm_url, protected_resource_metadata, resource_of, verify_access_token, www_authenticate };
@@ -94,7 +94,8 @@ const build_log_text = (b) => {
94
94
  ];
95
95
  return head.concat(b.content).join('\n');
96
96
  };
97
- const tools_for = (opts) => TOOLS.filter(t => opts.readonly ? t.annotations.readOnlyHint : !t.write || opts.writes);
97
+ const tools_for = (opts) => TOOLS.filter(t => (opts.readonly ? t.annotations.readOnlyHint : !t.write || opts.writes) &&
98
+ (!opts.allow || opts.allow(t)));
98
99
  const create_server = (opts = {}) => {
99
100
  const server = new McpServer({ name: 'faable', title: 'Faable', version }, { instructions: INSTRUCTIONS });
100
101
  for (const tool of tools_for(opts)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.5.5",
3
+ "version": "4.6.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",