@faable/faable 4.4.0 → 4.5.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.
@@ -10,12 +10,19 @@ import { CliError, NOT_LOGGED_IN } from '../lib/errors.js';
10
10
 
11
11
  const context = async (targetAppId) => {
12
12
  let api;
13
- // Auth resolution: FAABLE_TOKEN → OIDC (CI) → local `faable login` credentials.
13
+ // Auth resolution: FAABLE_TOKEN → FAABLE_API_KEY → OIDC (CI) → local
14
+ // `faable login` credentials.
14
15
  if (process.env.FAABLE_TOKEN) {
15
16
  // Token in environment
16
17
  const token = process.env.FAABLE_TOKEN;
17
18
  api = FaableApi.create({ authStrategy: bearer_strategy, auth: { token } });
18
19
  }
20
+ else if (process.env.FAABLE_API_KEY) {
21
+ // A dashboard API key, scoped to its project — how the hosted MCP server
22
+ // (`faable mcp --http`) runs each call with the caller's own key.
23
+ const apikey = process.env.FAABLE_API_KEY;
24
+ api = FaableApi.create({ authStrategy: apikey_strategy, auth: { apikey } });
25
+ }
19
26
  else if (process.env.GITHUB_ACTIONS === "true") {
20
27
  // Github actions environment
21
28
  try {
@@ -1,4 +1,5 @@
1
1
  import { ContextStore } from '../lib/ContextStore.js';
2
+ import { CliError } from '../lib/errors.js';
2
3
 
3
4
  let flag_value;
4
5
  // Set by the global middleware in src/index.ts.
@@ -50,7 +51,7 @@ const requireProject = async (api, store = new ContextStore(), env = process.env
50
51
  if (page.results.length === 1 && !page.next) {
51
52
  return { id: page.results[0].id, source: 'only' };
52
53
  }
53
- throw new Error('No project selected. Pick one with: faable project use <id|name> (list them with: faable project list), or pass --project <id|name>.');
54
+ throw new CliError('project_required', 'No project selected. Pick one with: faable project use <id|name> (list them with: faable project list), or pass --project <id|name>.');
54
55
  };
55
56
  const SOURCE_LABEL = {
56
57
  flag: '--project',
@@ -1,26 +1,44 @@
1
+ import { serve_http } from '../../mcp/http.js';
1
2
  import { serve_stdio } from '../../mcp/server.js';
2
3
 
3
- // `faable mcp` — this CLI as an MCP server, over stdio, for Claude Code,
4
- // Cursor and any MCP client:
4
+ // `faable mcp` — this CLI as an MCP server, for Claude Code, Cursor and any
5
+ // MCP client.
5
6
  //
6
- // claude mcp add faable -- npx -y @faable/faable mcp
7
+ // claude mcp add faable -- npx -y @faable/faable mcp (stdio, local)
8
+ // faable mcp --http --port 8080 (mcp.faable.com)
7
9
  //
8
- // It acts with the `faable login` session (or FAABLE_TOKEN), and every tool is
9
- // one run of this same CLI (src/mcp/run_cli.ts). stdout is the protocol from
10
- // here on: nothing else may print there.
10
+ // stdio acts with the `faable login` session (or FAABLE_TOKEN); --http with
11
+ // each caller's API key (src/mcp/http.ts). Every tool is one run of this same
12
+ // CLI (src/mcp/run_cli.ts). Over stdio, stdout is the protocol: nothing else
13
+ // may print there.
11
14
  const mcp = {
12
15
  command: 'mcp',
13
- describe: 'Run the Faable MCP server (stdio) for AI agents and editors',
16
+ describe: 'Run the Faable MCP server for AI agents and editors (stdio, or --http)',
14
17
  builder: yargs => yargs
15
18
  .option('writes', {
16
19
  type: 'boolean',
17
20
  default: false,
18
- description: 'Also expose the reversible writes: create apps, set secrets, add domains, retry or cancel builds, change deploy settings'
21
+ description: 'Also expose the reversible writes: create apps, set secrets, add domains, retry or cancel builds, change deploy settings (over HTTP: ?mode=write)'
22
+ })
23
+ .option('http', {
24
+ type: 'boolean',
25
+ default: false,
26
+ description: 'Serve Streamable HTTP for remote clients, authenticated per request with a Faable API key'
27
+ })
28
+ .option('port', {
29
+ type: 'number',
30
+ default: Number(process.env.PORT) || 8080,
31
+ description: 'With --http: the port (env PORT)'
19
32
  })
20
33
  .example('claude mcp add faable -- npx -y @faable/faable mcp', 'Add it to Claude Code (read-only + deploy)')
21
34
  .example('claude mcp add faable -- npx -y @faable/faable mcp --writes', 'With the reversible writes')
35
+ .example('$0 mcp --http --port 8080', 'Host it (what runs at mcp.faable.com)')
22
36
  .showHelpOnFail(false),
23
37
  handler: async (args) => {
38
+ if (args.http) {
39
+ await serve_http({ port: args.port ?? 8080 });
40
+ return;
41
+ }
24
42
  await serve_stdio({ writes: args.writes });
25
43
  }
26
44
  };
@@ -1,7 +1,8 @@
1
1
  import { getMe } from '../../api/auth.js';
2
2
  import { resolveTenant } from '../../api/auth_admin.js';
3
3
  import { FaableApi } from '../../api/FaableApi.js';
4
- import { SOURCE_LABEL, requireProject } from '../../api/project.js';
4
+ import { requireApi } from '../../api/context.js';
5
+ import { requireProject, SOURCE_LABEL } from '../../api/project.js';
5
6
  import { loadLiveCredentials } from '../../api/session.js';
6
7
  import { bearer_strategy } from '../../api/strategies/bearer.strategy.js';
7
8
  import { CredentialsStore } from '../../lib/CredentialsStore.js';
@@ -41,6 +42,25 @@ const whoami = {
41
42
  const config = await loadLiveCredentials(store);
42
43
  // Bearer token from the environment (CI) or a local `faable login`.
43
44
  const token = process.env.FAABLE_TOKEN || config?.token;
45
+ // An API key from the environment (the hosted MCP server). It cannot be
46
+ // introspected at /me, and it acts with its owner's access — not only on
47
+ // the project it was created in — so the project is whatever this call
48
+ // pinned, if anything.
49
+ if (!token && process.env.FAABLE_API_KEY) {
50
+ const { api } = await requireApi();
51
+ const project = await requireProject(api)
52
+ .then(async ({ id, source }) => ({
53
+ id,
54
+ name: (await api.getProject(id).catch(() => undefined))?.name,
55
+ source,
56
+ }))
57
+ .catch(() => null);
58
+ if (args.json)
59
+ return print_json({ credential: "apikey", project });
60
+ print("Logged in with: an API key");
61
+ print(`Project: ${project ? `${project.name ?? ""} (${project.id})` : "none — pass --project"}`);
62
+ return;
63
+ }
44
64
  if (!token) {
45
65
  // API-key sessions can't be introspected at the auth server's /me; fall
46
66
  // back to the email captured at login time.
@@ -0,0 +1,258 @@
1
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { z } from 'zod';
7
+ import { version } from '../config.js';
8
+ import { run_cli } from './run_cli.js';
9
+ import { tools_for, create_server } from './server.js';
10
+ import { TOOLS } from './tools.js';
11
+
12
+ // `faable mcp --http` — the hosted Faable MCP server (Fase 2 of
13
+ // arch/deploy/mcp-server-faable.md), what runs at mcp.faable.com.
14
+ //
15
+ // Streamable HTTP, stateless: every POST /mcp builds a server and a transport
16
+ // for that one request, so any instance can answer anything. The caller
17
+ // authenticates with a Faable API key (dashboard → project settings → API
18
+ // keys). ⚠️ A key acts with its OWNER's access, not only on the project it was
19
+ // created in (arch/deploy/mcp-cli-gaps.md) — /mcp/<project> is how a caller
20
+ // narrows it. Each tool call runs the CLI as a child
21
+ // with THAT key in its environment and an empty HOME of its own — two
22
+ // callers' credentials never meet in one process, and the key is never
23
+ // logged.
24
+ //
25
+ // POST /mcp everything the key's owner can see
26
+ // POST /mcp/<project> pinned to one project (id, name or slug)
27
+ // ?mode=write also the reversible writes
28
+ // ?readonly=1 reads only — not even deploy_app
29
+ const PUBLIC_URL = 'https://mcp.faable.com';
30
+ const DOCS_URL = 'https://faable.com/docs/cli#mcp-server';
31
+ const KEYS_URL = 'https://dashboard.faable.com/settings/projects';
32
+ const MAX_BODY_BYTES = 1024 * 1024;
33
+ // `Authorization: Bearer <key>`, or Basic with the key as the user (what the
34
+ // api itself accepts). Anything else is no credential.
35
+ const api_key_of = (req) => {
36
+ const header = req.headers.authorization ?? '';
37
+ const bearer = /^Bearer\s+(\S+)$/i.exec(header);
38
+ let key = bearer?.[1];
39
+ const basic = /^Basic\s+(\S+)$/i.exec(header);
40
+ if (!key && basic) {
41
+ key = Buffer.from(basic[1], 'base64').toString('utf8').split(':')[0];
42
+ }
43
+ return key && /^[\w.~+/=-]{8,256}$/.test(key) ? key : null;
44
+ };
45
+ // /mcp → {}, /mcp/<project> → { project }; anything else → null.
46
+ const route_of = (pathname) => {
47
+ const m = /^\/mcp(?:\/([^/]+))?\/?$/.exec(pathname);
48
+ if (!m)
49
+ return null;
50
+ return m[1] ? { project: decodeURIComponent(m[1]) } : {};
51
+ };
52
+ // The environment of one call: the server's own, minus every credential and
53
+ // context it might carry, plus the caller's key and an empty HOME.
54
+ const SCRUBBED = /^(FAABLE_(TOKEN|API_KEY|PROJECT|AUTH_ACCOUNT|AUTH_URL|ID_TOKEN)|GITHUB_ACTIONS|ACTIONS_ID_TOKEN_.*)$/;
55
+ const child_env = (key, home) => {
56
+ const env = {};
57
+ for (const [k, v] of Object.entries(process.env)) {
58
+ if (!SCRUBBED.test(k))
59
+ env[k] = v;
60
+ }
61
+ return { ...env, HOME: home, FAABLE_API_KEY: key };
62
+ };
63
+ // At most `max` children at once.
64
+ const limiter = (max) => {
65
+ let active = 0;
66
+ const waiting = [];
67
+ return async (fn) => {
68
+ if (active >= max)
69
+ await new Promise(r => waiting.push(r));
70
+ active++;
71
+ try {
72
+ return await fn();
73
+ }
74
+ finally {
75
+ active--;
76
+ waiting.shift()?.();
77
+ }
78
+ };
79
+ };
80
+ const runner_for = (key, limit, entry) => (argv, opts) => limit(async () => {
81
+ const home = await mkdtemp(path.join(os.tmpdir(), 'faable-mcp-'));
82
+ try {
83
+ return await run_cli(argv, {
84
+ ...opts,
85
+ entry,
86
+ env: child_env(key, home)
87
+ });
88
+ }
89
+ finally {
90
+ await rm(home, { recursive: true, force: true });
91
+ }
92
+ });
93
+ const send = (res, status, body, type = 'application/json') => {
94
+ res.writeHead(status, {
95
+ 'content-type': type === 'application/json' ? 'application/json; charset=utf-8' : type,
96
+ 'cache-control': 'no-store'
97
+ });
98
+ res.end(type === 'application/json' ? JSON.stringify(body) : String(body));
99
+ };
100
+ const read_body = (req) => new Promise((resolve, reject) => {
101
+ let size = 0;
102
+ const chunks = [];
103
+ req.on('data', (c) => {
104
+ size += c.length;
105
+ if (size > MAX_BODY_BYTES) {
106
+ reject(new Error('body too large'));
107
+ req.destroy();
108
+ }
109
+ else
110
+ chunks.push(c);
111
+ });
112
+ req.on('end', () => {
113
+ try {
114
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'));
115
+ }
116
+ catch {
117
+ reject(new Error('invalid JSON'));
118
+ }
119
+ });
120
+ req.on('error', reject);
121
+ });
122
+ const rpc_error = (code, message) => ({
123
+ jsonrpc: '2.0',
124
+ error: { code, message },
125
+ id: null
126
+ });
127
+ // The catalog as data: the landing (faable.com/mcp) and MCP directories read
128
+ // it, so the published list is always the served one.
129
+ const catalog = (base = PUBLIC_URL) => ({
130
+ name: 'faable',
131
+ version,
132
+ endpoint: `${base}/mcp`,
133
+ transport: 'streamable-http',
134
+ auth: {
135
+ type: 'api-key',
136
+ header: 'Authorization: Bearer <key>',
137
+ keys: KEYS_URL
138
+ },
139
+ docs: DOCS_URL,
140
+ tools: TOOLS.map(t => ({
141
+ name: t.name,
142
+ title: t.title,
143
+ description: t.description,
144
+ access: t.annotations.readOnlyHint ? 'read' : t.write ? 'write' : 'deploy',
145
+ requires: t.write ? 'mode=write' : undefined,
146
+ input_schema: z.toJSONSchema(z.object(t.input))
147
+ }))
148
+ });
149
+ const llms_txt = (base = PUBLIC_URL) => [
150
+ '# Faable MCP server',
151
+ '',
152
+ '> 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
+ '',
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 acts with its owner's access; connect to \`${base}/mcp/<project>\` to keep every call in one project.`,
155
+ '',
156
+ `- \`${base}/mcp/<project>\` pins every call to one project.`,
157
+ '- `?mode=write` adds the reversible writes (create apps, set secrets, add domains, retry or cancel builds, change deploy settings). Nothing destructive is exposed.',
158
+ '- `?readonly=1` exposes reads only.',
159
+ '- Local alternative, no key: `npx -y @faable/faable mcp` (uses `faable login`).',
160
+ '',
161
+ '## Tools',
162
+ '',
163
+ ...TOOLS.map(t => `- \`${t.name}\`${t.write ? ' (mode=write)' : ''}: ${t.description}`),
164
+ '',
165
+ `Docs: ${DOCS_URL}`,
166
+ ''
167
+ ].join('\n');
168
+ const create_http_server = (opts) => {
169
+ const limit = limiter(opts.max_concurrent ?? 8);
170
+ const base = opts.base_url ?? PUBLIC_URL;
171
+ return http.createServer(async (req, res) => {
172
+ const started = Date.now();
173
+ const url = new URL(req.url ?? '/', 'http://localhost');
174
+ let status = 200;
175
+ const done = () =>
176
+ // One line per request, never the key nor the body.
177
+ process.stderr.write(JSON.stringify({
178
+ msg: 'mcp_http',
179
+ method: req.method,
180
+ path: url.pathname.replace(/^\/mcp\/.+/, '/mcp/:project'),
181
+ status: res.statusCode || status,
182
+ ms: Date.now() - started
183
+ }) + '\n');
184
+ res.on('finish', done);
185
+ try {
186
+ if (req.method === 'GET' &&
187
+ (url.pathname === '/healthz' || url.pathname === '/livez')) {
188
+ return send(res, 200, { ok: true, version });
189
+ }
190
+ if (req.method === 'GET' && url.pathname === '/') {
191
+ res.writeHead(302, { location: DOCS_URL });
192
+ return res.end();
193
+ }
194
+ if (req.method === 'GET' && url.pathname === '/tools.json') {
195
+ return send(res, 200, catalog(base));
196
+ }
197
+ if (req.method === 'GET' && url.pathname === '/llms.txt') {
198
+ return send(res, 200, llms_txt(base), 'text/plain; charset=utf-8');
199
+ }
200
+ const route = route_of(url.pathname);
201
+ if (!route)
202
+ return send(res, 404, { error: 'not found', docs: DOCS_URL });
203
+ if (req.method !== 'POST') {
204
+ // Stateless: no server-initiated stream, no session to delete.
205
+ res.setHeader('allow', 'POST');
206
+ return send(res, 405, rpc_error(-32000, 'Method not allowed: this server is stateless, use POST.'));
207
+ }
208
+ const key = api_key_of(req);
209
+ if (!key) {
210
+ res.setHeader('www-authenticate', 'Bearer realm="faable"');
211
+ 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}`));
213
+ }
214
+ let body;
215
+ try {
216
+ body = await read_body(req);
217
+ }
218
+ catch (e) {
219
+ return send(res, 400, rpc_error(-32700, e.message));
220
+ }
221
+ const server = create_server({
222
+ writes: url.searchParams.get('mode') === 'write',
223
+ readonly: ['1', 'true'].includes(url.searchParams.get('readonly') ?? ''),
224
+ project: route.project,
225
+ run: runner_for(key, limit, opts.entry)
226
+ });
227
+ const transport = new StreamableHTTPServerTransport({
228
+ sessionIdGenerator: undefined,
229
+ enableJsonResponse: true
230
+ });
231
+ res.on('close', () => {
232
+ transport.close().catch(() => undefined);
233
+ server.close().catch(() => undefined);
234
+ });
235
+ await server.connect(transport);
236
+ await transport.handleRequest(req, res, body);
237
+ }
238
+ catch (e) {
239
+ if (!res.headersSent)
240
+ send(res, 500, rpc_error(-32603, 'Internal error'));
241
+ process.stderr.write(JSON.stringify({ msg: 'mcp_http_error', error: e.message }) +
242
+ '\n');
243
+ }
244
+ });
245
+ };
246
+ const serve_http = async (opts) => {
247
+ const server = create_http_server(opts);
248
+ await new Promise(resolve => server.listen(opts.port, opts.host ?? '0.0.0.0', resolve));
249
+ process.stderr.write(JSON.stringify({
250
+ msg: 'mcp_http_listening',
251
+ port: opts.port,
252
+ version,
253
+ tools: tools_for({}).length
254
+ }) + '\n');
255
+ return server;
256
+ };
257
+
258
+ export { PUBLIC_URL, api_key_of, catalog, child_env, create_http_server, llms_txt, route_of, serve_http };
@@ -1,11 +1,27 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
2
3
  import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
3
6
 
4
7
  const DEFAULT_TIMEOUT_MS = 120_000;
8
+ // import.meta.resolve where the runtime has it; where a transpiler shims
9
+ // import.meta (tsx under a test runner), the CommonJS resolver from the
10
+ // current directory finds the same file.
11
+ const resolve_loader = (spec) => {
12
+ try {
13
+ if (typeof import.meta.resolve === 'function')
14
+ return import.meta.resolve(spec);
15
+ }
16
+ catch {
17
+ // fall through
18
+ }
19
+ return pathToFileURL(createRequire(path.join(process.cwd(), 'noop.js')).resolve(spec)).href;
20
+ };
5
21
  // The child runs from the temp dir (M3), where a bare `--import tsx` (how the
6
22
  // tests and `npm run cli` start this CLI) would no longer resolve. Resolve
7
23
  // such loaders here, from the CLI's own location, before handing them on.
8
- const portable_exec_argv = (exec_argv = process.execArgv, resolve = spec => import.meta.resolve(spec)) => {
24
+ const portable_exec_argv = (exec_argv = process.execArgv, resolve = resolve_loader) => {
9
25
  const bare = (spec) => !/^(\.|\/|file:|node:|data:)/.test(spec);
10
26
  return exec_argv.map((arg, i) => {
11
27
  if (exec_argv[i - 1] === '--import' && bare(arg))
@@ -35,6 +35,7 @@ const NEXT_STEP = {
35
35
  not_found: 'Check the id or name — list_apps / list_deployments show what exists.',
36
36
  forbidden: 'This session has no access there — check the project (list_projects).',
37
37
  app_required: 'Pass `app` (id, name or slug).',
38
+ project_required: 'This account sees several projects: call list_projects and pass `project` (on the hosted server, connecting to /mcp/<project> pins it).',
38
39
  timeout: 'Faable did not answer in time; retry once, then tell the user.'
39
40
  };
40
41
  const error_text = (e) => {
@@ -92,7 +93,7 @@ const build_log_text = (b) => {
92
93
  ];
93
94
  return head.concat(b.content).join('\n');
94
95
  };
95
- const tools_for = (opts) => TOOLS.filter(t => !t.write || opts.writes);
96
+ const tools_for = (opts) => TOOLS.filter(t => opts.readonly ? t.annotations.readOnlyHint : !t.write || opts.writes);
96
97
  const create_server = (opts = {}) => {
97
98
  const server = new McpServer({ name: 'faable', title: 'Faable', version }, { instructions: INSTRUCTIONS });
98
99
  for (const tool of tools_for(opts)) {
@@ -101,7 +102,11 @@ const create_server = (opts = {}) => {
101
102
  description: tool.description,
102
103
  inputSchema: tool.input,
103
104
  annotations: { title: tool.title, ...tool.annotations }
104
- }, ((args) => call_tool(tool, args ?? {})));
105
+ }, ((args) => call_tool(tool,
106
+ // A pinned project wins over whatever the agent passes.
107
+ opts.project
108
+ ? { ...(args ?? {}), project: opts.project }
109
+ : (args ?? {}), opts.run)));
105
110
  }
106
111
  return server;
107
112
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -29,7 +29,7 @@
29
29
  "@actions/core": "^3.0.0",
30
30
  "@faable/auth-sdk": "^2.7.29",
31
31
  "@modelcontextprotocol/sdk": "^1.31.0",
32
- "axios": "^1.18.1",
32
+ "axios": "^1.20.0",
33
33
  "fs-extra": "^11.3.2",
34
34
  "open": "^11.0.0",
35
35
  "ora": "^9.4.0",