@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.
- package/dist/api/context.js +8 -1
- package/dist/api/project.js +2 -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/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',
|
|
@@ -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.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.
|
|
32
|
+
"axios": "^1.20.0",
|
|
33
33
|
"fs-extra": "^11.3.2",
|
|
34
34
|
"open": "^11.0.0",
|
|
35
35
|
"ora": "^9.4.0",
|