@faable/faable 4.4.0 → 4.5.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.
- package/dist/api/FaableApi.js +8 -4
- package/dist/api/context.js +8 -1
- package/dist/api/project.js +2 -1
- package/dist/commands/deploy/apps/create.js +2 -2
- package/dist/commands/deploy/inspect/trigger.js +1 -1
- package/dist/commands/link/index.js +1 -1
- package/dist/commands/mcp/index.js +26 -8
- package/dist/commands/whoami/index.js +21 -1
- package/dist/mcp/http.js +258 -0
- package/dist/mcp/run_cli.js +17 -1
- package/dist/mcp/server.js +7 -2
- package/package.json +2 -2
package/dist/api/FaableApi.js
CHANGED
|
@@ -227,8 +227,12 @@ class FaableApi {
|
|
|
227
227
|
async updateApp(app_id, params) {
|
|
228
228
|
return data(this.client.post(`/app/${app_id}`, params));
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
// Team pinned via header: the api answers 400 team_required without it
|
|
231
|
+
// (it needs the project to check the plan and the repository guard).
|
|
232
|
+
async linkRepository(app_id, params, team) {
|
|
233
|
+
return data(this.client.post(`/app/${app_id}/link-repository`, params, {
|
|
234
|
+
headers: projectHeader(team)
|
|
235
|
+
}));
|
|
232
236
|
}
|
|
233
237
|
// Organizations/accounts where the Faable GitHub App is installed.
|
|
234
238
|
async listGithubInstallations() {
|
|
@@ -259,11 +263,11 @@ class FaableApi {
|
|
|
259
263
|
}
|
|
260
264
|
// Build and deploy the current head of the deploy branch server-side —
|
|
261
265
|
// the same path a push webhook takes, same-commit dedupe included.
|
|
262
|
-
async deployNow(app_id) {
|
|
266
|
+
async deployNow(app_id, team) {
|
|
263
267
|
return data(
|
|
264
268
|
// deployment_id: the build this call started (api ≥ the release that
|
|
265
269
|
// added it; absent from older ones).
|
|
266
|
-
this.client.post(`/app/${app_id}/deploy
|
|
270
|
+
this.client.post(`/app/${app_id}/deploy`, undefined, { headers: projectHeader(team) }));
|
|
267
271
|
}
|
|
268
272
|
// Rebuild a failed deployment from its recorded source (CAS manifest or
|
|
269
273
|
// git ref). The API enforces the guards: failed phase only, never older
|
package/dist/api/context.js
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/api/project.js
CHANGED
|
@@ -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
|
|
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',
|
|
@@ -81,7 +81,7 @@ const apps_create = {
|
|
|
81
81
|
linked = await api.linkRepository(app.id, {
|
|
82
82
|
repository,
|
|
83
83
|
...(args.branch ? { github_branch: args.branch } : {})
|
|
84
|
-
});
|
|
84
|
+
}, app.team);
|
|
85
85
|
}
|
|
86
86
|
catch (err) {
|
|
87
87
|
await api.deleteApp(app.id, app.team).catch(() => {
|
|
@@ -102,7 +102,7 @@ const apps_create = {
|
|
|
102
102
|
let first_deploy = null;
|
|
103
103
|
if (repository && args.deploy && linked.deploy_trigger === 'webhook') {
|
|
104
104
|
try {
|
|
105
|
-
const started = await api.deployNow(app.id);
|
|
105
|
+
const started = await api.deployNow(app.id, app.team);
|
|
106
106
|
first_deploy = {
|
|
107
107
|
commit: started.commit,
|
|
108
108
|
branch: started.branch,
|
|
@@ -20,7 +20,7 @@ const trigger = {
|
|
|
20
20
|
const app = await ctx.api.getApp(app_id);
|
|
21
21
|
// Takes the exact same path a git push would (same-commit dedupe
|
|
22
22
|
// included) — the API answers with an actionable refusal otherwise.
|
|
23
|
-
const result = await ctx.api.deployNow(app_id);
|
|
23
|
+
const result = await ctx.api.deployNow(app_id, app.team);
|
|
24
24
|
if (args.json)
|
|
25
25
|
return print_json({ app_id, ...result });
|
|
26
26
|
log.info(`🚀 Building ${result.commit.slice(0, 7)} (${result.branch}) of ${app.name} server-side${result.deployment_id ? ` as ${result.deployment_id}` : ''}.`);
|
|
@@ -77,7 +77,7 @@ const link = {
|
|
|
77
77
|
// access to the repository before persisting the link.
|
|
78
78
|
let linked;
|
|
79
79
|
try {
|
|
80
|
-
linked = await api.linkRepository(selectedApp.id, { repository: gitUrl });
|
|
80
|
+
linked = await api.linkRepository(selectedApp.id, { repository: gitUrl }, selectedApp.team);
|
|
81
81
|
}
|
|
82
82
|
catch (err) {
|
|
83
83
|
const code = err?.code;
|
|
@@ -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,
|
|
4
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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 {
|
|
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.
|
package/dist/mcp/http.js
ADDED
|
@@ -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 };
|
package/dist/mcp/run_cli.js
CHANGED
|
@@ -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 =
|
|
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))
|
package/dist/mcp/server.js
CHANGED
|
@@ -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,
|
|
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.
|
|
3
|
+
"version": "4.5.1",
|
|
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.
|
|
32
|
+
"axios": "^1.20.0",
|
|
33
33
|
"fs-extra": "^11.3.2",
|
|
34
34
|
"open": "^11.0.0",
|
|
35
35
|
"ora": "^9.4.0",
|