@ferrlabs/mcp 5.0.0 → 5.1.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/README.md +39 -9
- package/dist/auth/__tests__/index.test.d.ts +2 -0
- package/dist/auth/__tests__/index.test.d.ts.map +1 -0
- package/dist/auth/__tests__/index.test.js +39 -0
- package/dist/auth/__tests__/oauth.test.d.ts +2 -0
- package/dist/auth/__tests__/oauth.test.d.ts.map +1 -0
- package/dist/auth/__tests__/oauth.test.js +26 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +36 -0
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +123 -0
- package/dist/auth/persistence.d.ts +4 -0
- package/dist/auth/persistence.d.ts.map +1 -0
- package/dist/auth/persistence.js +61 -0
- package/dist/tools/issues.d.ts.map +1 -1
- package/dist/tools/issues.js +2 -8
- package/dist/tools/orgs.d.ts.map +1 -1
- package/dist/tools/orgs.js +3 -9
- package/dist/tools/subscriptions.d.ts.map +1 -1
- package/dist/tools/subscriptions.js +2 -8
- package/dist/tools/tokens.d.ts.map +1 -1
- package/dist/tools/tokens.js +5 -11
- package/dist/tools/vaults.d.ts.map +1 -1
- package/dist/tools/vaults.js +2 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,21 +10,36 @@
|
|
|
10
10
|
|
|
11
11
|
Add to your MCP client configuration (Claude Code, Cursor, etc.):
|
|
12
12
|
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"ferrlabs": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@ferrlabs/mcp"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The first time you call a tool that needs auth, the MCP opens `auth.ferrlabs.com` in your default browser (OAuth 2.0 loopback PKCE). After you click Allow, the token is saved locally and reused for future sessions. No env var required.
|
|
25
|
+
|
|
26
|
+
### CI / scripted / headless use
|
|
27
|
+
|
|
28
|
+
Bypass the browser dance by injecting a token directly:
|
|
29
|
+
|
|
13
30
|
```json
|
|
14
31
|
{
|
|
15
32
|
"mcpServers": {
|
|
16
33
|
"ferrlabs": {
|
|
17
34
|
"command": "npx",
|
|
18
35
|
"args": ["-y", "@ferrlabs/mcp"],
|
|
19
|
-
"env": {
|
|
20
|
-
"FERRLABS_API_TOKEN": "fl_..."
|
|
21
|
-
}
|
|
36
|
+
"env": { "FERRLABS_API_TOKEN": "fl_..." }
|
|
22
37
|
}
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
```
|
|
26
41
|
|
|
27
|
-
Create
|
|
42
|
+
Create the token from `app.ferrlabs.com` → Settings → API Tokens. It's forwarded as `x-api-token` to `api.ferrlabs.com`.
|
|
28
43
|
|
|
29
44
|
## Available Tools
|
|
30
45
|
|
|
@@ -55,11 +70,26 @@ FerrFlow CLI-specific tools (`dry_run`, `validate_config`, `read_config`, `read_
|
|
|
55
70
|
|
|
56
71
|
## Configuration
|
|
57
72
|
|
|
58
|
-
| Variable
|
|
59
|
-
|
|
|
60
|
-
| `API_URL`
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
73
|
+
| Variable | Description | Default |
|
|
74
|
+
| ------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
75
|
+
| `API_URL` | FerrLabs API base URL (no `/v1` suffix — paths are prefixed in code) | `https://api.ferrlabs.com` |
|
|
76
|
+
| `FERRLABS_AUTH_URL` | Auth SPA base URL (where the OAuth browser flow lands) | `https://auth.ferrlabs.com` |
|
|
77
|
+
| `FERRLABS_API_TOKEN` | Pre-provisioned token. Bypasses the OAuth flow. Use for CI / scripted environments. | — |
|
|
78
|
+
| `FERRFLOW_API_TOKEN` | **Deprecated** — accepted as a fallback for backward compatibility with v3.x. | — |
|
|
79
|
+
| `FERRLABS_MCP_NO_OAUTH` | Set to `1` to disable the OAuth fallback. Then `FERRLABS_API_TOKEN` becomes required. | unset |
|
|
80
|
+
| `FERRLABS_MCP_TOKEN_PATH` | Override the path where the OAuth-acquired token is persisted. | `%APPDATA%\ferrlabs\mcp\token.json` / `~/.config/ferrlabs/mcp/token.json` |
|
|
81
|
+
| `FERRLABS_MCP_NO_PERSIST` | Set to `1` to keep the token in memory only (re-auth on every cold start). | unset |
|
|
82
|
+
|
|
83
|
+
### How auth resolution works
|
|
84
|
+
|
|
85
|
+
On the first authenticated tool call, the MCP looks for a token in this order:
|
|
86
|
+
|
|
87
|
+
1. In-memory cache (set during this MCP process's lifetime)
|
|
88
|
+
2. `FERRLABS_API_TOKEN` (or `FERRFLOW_API_TOKEN`) env var
|
|
89
|
+
3. Persisted token file (`FERRLABS_MCP_TOKEN_PATH`)
|
|
90
|
+
4. **OAuth 2.0 loopback PKCE flow** — opens the user's browser, captures the callback on `http://127.0.0.1:54321/cb`, exchanges the code for a session token via `POST /v1/auth/exchange`. Token is persisted to step 3 for future sessions.
|
|
91
|
+
|
|
92
|
+
If you set `FERRLABS_MCP_NO_OAUTH=1`, step 4 is skipped — useful for CI where opening a browser would hang.
|
|
63
93
|
|
|
64
94
|
## Smoke test
|
|
65
95
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
describe('getToken', () => {
|
|
3
|
+
const originalEnv = process.env;
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
process.env = { ...originalEnv };
|
|
7
|
+
delete process.env.FERRLABS_API_TOKEN;
|
|
8
|
+
delete process.env.FERRFLOW_API_TOKEN;
|
|
9
|
+
delete process.env.FERRLABS_MCP_NO_OAUTH;
|
|
10
|
+
process.env.FERRLABS_MCP_NO_PERSIST = '1';
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
process.env = originalEnv;
|
|
14
|
+
});
|
|
15
|
+
it('returns FERRLABS_API_TOKEN when set, without touching persistence or OAuth', async () => {
|
|
16
|
+
process.env.FERRLABS_API_TOKEN = 'fl_env_token';
|
|
17
|
+
const { getToken } = await import('../index.js');
|
|
18
|
+
expect(await getToken()).toBe('fl_env_token');
|
|
19
|
+
});
|
|
20
|
+
it('falls back to FERRFLOW_API_TOKEN (legacy compat)', async () => {
|
|
21
|
+
process.env.FERRFLOW_API_TOKEN = 'fl_legacy_token';
|
|
22
|
+
const { getToken } = await import('../index.js');
|
|
23
|
+
expect(await getToken()).toBe('fl_legacy_token');
|
|
24
|
+
});
|
|
25
|
+
it('throws when no token and FERRLABS_MCP_NO_OAUTH=1', async () => {
|
|
26
|
+
process.env.FERRLABS_MCP_NO_OAUTH = '1';
|
|
27
|
+
const { getToken } = await import('../index.js');
|
|
28
|
+
await expect(getToken()).rejects.toThrow(/FERRLABS_API_TOKEN environment variable is required/);
|
|
29
|
+
});
|
|
30
|
+
it('caches the token across calls (env path)', async () => {
|
|
31
|
+
process.env.FERRLABS_API_TOKEN = 'fl_cached';
|
|
32
|
+
const { getToken } = await import('../index.js');
|
|
33
|
+
const a = await getToken();
|
|
34
|
+
process.env.FERRLABS_API_TOKEN = 'fl_changed';
|
|
35
|
+
const b = await getToken();
|
|
36
|
+
expect(a).toBe(b);
|
|
37
|
+
expect(a).toBe('fl_cached');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/oauth.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generatePkcePair } from '../oauth.js';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
describe('generatePkcePair', () => {
|
|
5
|
+
it('returns a verifier between 43 and 128 chars (RFC 7636)', () => {
|
|
6
|
+
const { verifier } = generatePkcePair();
|
|
7
|
+
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|
8
|
+
expect(verifier.length).toBeLessThanOrEqual(128);
|
|
9
|
+
});
|
|
10
|
+
it('returns a challenge that is SHA-256(verifier) base64url-encoded', () => {
|
|
11
|
+
const { verifier, challenge } = generatePkcePair();
|
|
12
|
+
const expected = createHash('sha256').update(verifier).digest('base64url');
|
|
13
|
+
expect(challenge).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
it('returns different verifier+challenge pairs each call', () => {
|
|
16
|
+
const a = generatePkcePair();
|
|
17
|
+
const b = generatePkcePair();
|
|
18
|
+
expect(a.verifier).not.toBe(b.verifier);
|
|
19
|
+
expect(a.challenge).not.toBe(b.challenge);
|
|
20
|
+
});
|
|
21
|
+
it('returns base64url-encoded values (no +, /, =)', () => {
|
|
22
|
+
const { verifier, challenge } = generatePkcePair();
|
|
23
|
+
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
24
|
+
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAMA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAgChD;AAED,wBAAgB,yBAAyB,IAAI,IAAI,CAGhD"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readPersistedToken, writePersistedToken } from './persistence.js';
|
|
2
|
+
import { runLoopbackOauthFlow } from './oauth.js';
|
|
3
|
+
let cached = null;
|
|
4
|
+
let inFlight = null;
|
|
5
|
+
export async function getToken() {
|
|
6
|
+
if (cached)
|
|
7
|
+
return cached;
|
|
8
|
+
const envToken = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
|
|
9
|
+
if (envToken) {
|
|
10
|
+
cached = envToken;
|
|
11
|
+
return envToken;
|
|
12
|
+
}
|
|
13
|
+
const persisted = await readPersistedToken();
|
|
14
|
+
if (persisted) {
|
|
15
|
+
cached = persisted;
|
|
16
|
+
return persisted;
|
|
17
|
+
}
|
|
18
|
+
if (process.env.FERRLABS_MCP_NO_OAUTH === '1') {
|
|
19
|
+
throw new Error('FERRLABS_API_TOKEN environment variable is required (FERRLABS_MCP_NO_OAUTH=1 disables the OAuth fallback).');
|
|
20
|
+
}
|
|
21
|
+
if (!inFlight) {
|
|
22
|
+
inFlight = (async () => {
|
|
23
|
+
const { token } = await runLoopbackOauthFlow();
|
|
24
|
+
await writePersistedToken(token);
|
|
25
|
+
cached = token;
|
|
26
|
+
return token;
|
|
27
|
+
})().finally(() => {
|
|
28
|
+
inFlight = null;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return inFlight;
|
|
32
|
+
}
|
|
33
|
+
export function resetTokenCacheForTesting() {
|
|
34
|
+
cached = null;
|
|
35
|
+
inFlight = null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface PkcePair {
|
|
2
|
+
verifier: string;
|
|
3
|
+
challenge: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function generatePkcePair(): PkcePair;
|
|
6
|
+
export interface RunLoopbackFlowResult {
|
|
7
|
+
token: string;
|
|
8
|
+
scope?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function runLoopbackOauthFlow(): Promise<RunLoopbackFlowResult>;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAWA,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,IAAI,QAAQ,CAI3C;AA4FD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CA+C3E"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { platform } from 'node:os';
|
|
5
|
+
const AUTH_BASE = process.env.FERRLABS_AUTH_URL ?? 'https://auth.ferrlabs.com';
|
|
6
|
+
const API_BASE = process.env.API_URL ?? 'https://api.ferrlabs.com';
|
|
7
|
+
const CLIENT_ID = 'mcp';
|
|
8
|
+
const REDIRECT_PORTS = [54321, 54322];
|
|
9
|
+
const AUTH_TIMEOUT_MS = 2 * 60 * 1000;
|
|
10
|
+
export function generatePkcePair() {
|
|
11
|
+
const verifier = randomBytes(64).toString('base64url');
|
|
12
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
13
|
+
return { verifier, challenge };
|
|
14
|
+
}
|
|
15
|
+
function generateState() {
|
|
16
|
+
return randomBytes(16).toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
function openBrowser(url) {
|
|
19
|
+
const args = (() => {
|
|
20
|
+
switch (platform()) {
|
|
21
|
+
case 'win32':
|
|
22
|
+
return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
|
|
23
|
+
case 'darwin':
|
|
24
|
+
return { cmd: 'open', args: [url] };
|
|
25
|
+
default:
|
|
26
|
+
return { cmd: 'xdg-open', args: [url] };
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
const child = spawn(args.cmd, args.args, { detached: true, stdio: 'ignore' });
|
|
30
|
+
child.on('error', () => {
|
|
31
|
+
// ignore — caller will time out and surface the URL via error
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
}
|
|
35
|
+
function listenForCallback(port, expectedState) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const server = createServer((req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
40
|
+
if (url.pathname !== '/cb') {
|
|
41
|
+
res.writeHead(404).end('Not found');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const code = url.searchParams.get('code');
|
|
45
|
+
const state = url.searchParams.get('state');
|
|
46
|
+
if (!code || !state) {
|
|
47
|
+
res.writeHead(400).end('Missing code or state');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (state !== expectedState) {
|
|
51
|
+
res.writeHead(400).end('state mismatch');
|
|
52
|
+
reject(new Error('OAuth callback state mismatch (CSRF protection triggered)'));
|
|
53
|
+
server.close();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
|
|
57
|
+
res.end("<!doctype html><html><body style='font-family:sans-serif;padding:2rem;color:#1e293b'><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
|
|
58
|
+
setImmediate(() => server.close());
|
|
59
|
+
resolve({ code, state });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
res.writeHead(500).end('Internal error');
|
|
63
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
server.on('error', reject);
|
|
67
|
+
server.listen(port, '127.0.0.1');
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function exchangeCodeForToken(code, codeVerifier, redirectUri) {
|
|
71
|
+
const res = await fetch(`${API_BASE}/v1/auth/exchange`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'ferrlabs-mcp/oauth' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
code,
|
|
76
|
+
client_id: CLIENT_ID,
|
|
77
|
+
redirect_uri: redirectUri,
|
|
78
|
+
code_verifier: codeVerifier,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const body = (await res.json().catch(() => ({})));
|
|
83
|
+
throw new Error(`OAuth exchange failed (HTTP ${res.status}): ${body.error ?? body.message ?? 'unknown'}`);
|
|
84
|
+
}
|
|
85
|
+
const data = (await res.json());
|
|
86
|
+
if (!data.token)
|
|
87
|
+
throw new Error('OAuth exchange returned no token');
|
|
88
|
+
return data.token;
|
|
89
|
+
}
|
|
90
|
+
export async function runLoopbackOauthFlow() {
|
|
91
|
+
const pkce = generatePkcePair();
|
|
92
|
+
const state = generateState();
|
|
93
|
+
let port = null;
|
|
94
|
+
let callbackPromise = null;
|
|
95
|
+
for (const candidate of REDIRECT_PORTS) {
|
|
96
|
+
try {
|
|
97
|
+
callbackPromise = listenForCallback(candidate, state);
|
|
98
|
+
port = candidate;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
callbackPromise = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (port === null || !callbackPromise) {
|
|
106
|
+
throw new Error(`Could not bind any of the loopback ports (${REDIRECT_PORTS.join(', ')}). Close whatever is using them and retry.`);
|
|
107
|
+
}
|
|
108
|
+
const redirectUri = `http://127.0.0.1:${port}/cb`;
|
|
109
|
+
const authorizeUrl = new URL(`${AUTH_BASE}/authorize`);
|
|
110
|
+
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
|
111
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
112
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
113
|
+
authorizeUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
114
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
115
|
+
authorizeUrl.searchParams.set('state', state);
|
|
116
|
+
openBrowser(authorizeUrl.toString());
|
|
117
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
118
|
+
setTimeout(() => reject(new Error(`OAuth flow timed out after ${AUTH_TIMEOUT_MS / 1000}s. If your browser didn't open, visit: ${authorizeUrl.toString()}`)), AUTH_TIMEOUT_MS);
|
|
119
|
+
});
|
|
120
|
+
const { code } = await Promise.race([callbackPromise, timeoutPromise]);
|
|
121
|
+
const token = await exchangeCodeForToken(code, pkce.verifier, redirectUri);
|
|
122
|
+
return { token };
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/auth/persistence.ts"],"names":[],"mappings":"AAmCA,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYjE;AAED,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBzF;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAOzD"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
function defaultTokenPath() {
|
|
5
|
+
const override = process.env.FERRLABS_MCP_TOKEN_PATH;
|
|
6
|
+
if (override)
|
|
7
|
+
return override;
|
|
8
|
+
const home = homedir();
|
|
9
|
+
switch (platform()) {
|
|
10
|
+
case 'win32':
|
|
11
|
+
return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'ferrlabs', 'mcp', 'token.json');
|
|
12
|
+
case 'darwin':
|
|
13
|
+
return join(home, 'Library', 'Application Support', 'ferrlabs', 'mcp', 'token.json');
|
|
14
|
+
default:
|
|
15
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'ferrlabs', 'mcp', 'token.json');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function readPersistedToken() {
|
|
19
|
+
if (process.env.FERRLABS_MCP_NO_PERSIST === '1')
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(defaultTokenPath(), 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed.token === 'string' && parsed.token.length > 0) {
|
|
25
|
+
return parsed.token;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function writePersistedToken(token, scopes) {
|
|
34
|
+
if (process.env.FERRLABS_MCP_NO_PERSIST === '1')
|
|
35
|
+
return;
|
|
36
|
+
const path = defaultTokenPath();
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
const payload = {
|
|
39
|
+
token,
|
|
40
|
+
obtained_at: new Date().toISOString(),
|
|
41
|
+
scopes,
|
|
42
|
+
};
|
|
43
|
+
await writeFile(path, JSON.stringify(payload, null, 2), 'utf8');
|
|
44
|
+
if (platform() !== 'win32') {
|
|
45
|
+
try {
|
|
46
|
+
await chmod(path, 0o600);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ignore — best effort on platforms that don't support POSIX modes
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function clearPersistedToken() {
|
|
54
|
+
const { unlink } = await import('node:fs/promises');
|
|
55
|
+
try {
|
|
56
|
+
await unlink(defaultTokenPath());
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"issues.d.ts","sourceRoot":"","sources":["../../src/tools/issues.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"issues.d.ts","sourceRoot":"","sources":["../../src/tools/issues.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAsBpE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,QA+BpD"}
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { apiRequest } from './api-client.js';
|
|
3
|
-
|
|
4
|
-
function requireToken() {
|
|
5
|
-
if (!API_TOKEN) {
|
|
6
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required for authenticated operations.');
|
|
7
|
-
}
|
|
8
|
-
return API_TOKEN;
|
|
9
|
-
}
|
|
3
|
+
import { getToken } from '../auth/index.js';
|
|
10
4
|
export function registerIssuesTools(server) {
|
|
11
5
|
server.tool('list_issues', 'List FerrTrack issues for a project (FerrTrack — issue tracker)', {
|
|
12
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
|
@@ -20,7 +14,7 @@ export function registerIssuesTools(server) {
|
|
|
20
14
|
.optional()
|
|
21
15
|
.describe('Items per page (default 25, max 100)'),
|
|
22
16
|
}, async ({ org_slug, project_slug, page, per_page }) => {
|
|
23
|
-
const token =
|
|
17
|
+
const token = await getToken();
|
|
24
18
|
const params = new URLSearchParams();
|
|
25
19
|
if (page !== undefined)
|
|
26
20
|
params.set('page', String(page));
|
package/dist/tools/orgs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orgs.d.ts","sourceRoot":"","sources":["../../src/tools/orgs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"orgs.d.ts","sourceRoot":"","sources":["../../src/tools/orgs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAqBpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,QA+BlD"}
|
package/dist/tools/orgs.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { apiRequest } from './api-client.js';
|
|
3
|
-
|
|
4
|
-
function requireToken() {
|
|
5
|
-
if (!API_TOKEN) {
|
|
6
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required for authenticated operations.');
|
|
7
|
-
}
|
|
8
|
-
return API_TOKEN;
|
|
9
|
-
}
|
|
3
|
+
import { getToken } from '../auth/index.js';
|
|
10
4
|
export function registerOrgsTools(server) {
|
|
11
5
|
server.tool('list_orgs', 'List FerrLabs organizations the authenticated user belongs to', {}, async () => {
|
|
12
|
-
const token =
|
|
6
|
+
const token = await getToken();
|
|
13
7
|
const orgs = await apiRequest('/v1/orgs', { token });
|
|
14
8
|
return {
|
|
15
9
|
content: [{ type: 'text', text: JSON.stringify(orgs, null, 2) }],
|
|
@@ -18,7 +12,7 @@ export function registerOrgsTools(server) {
|
|
|
18
12
|
server.tool('list_projects', 'List projects inside a FerrLabs organization', {
|
|
19
13
|
org_slug: z.string().min(1).describe('Organization slug (from list_orgs)'),
|
|
20
14
|
}, async ({ org_slug }) => {
|
|
21
|
-
const token =
|
|
15
|
+
const token = await getToken();
|
|
22
16
|
const projects = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/projects`, { token });
|
|
23
17
|
return {
|
|
24
18
|
content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../../src/tools/subscriptions.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../../src/tools/subscriptions.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAgBpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,SAAS,QAkB3D"}
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { apiRequest } from './api-client.js';
|
|
3
|
-
|
|
4
|
-
function requireToken() {
|
|
5
|
-
if (!API_TOKEN) {
|
|
6
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required for authenticated operations.');
|
|
7
|
-
}
|
|
8
|
-
return API_TOKEN;
|
|
9
|
-
}
|
|
3
|
+
import { getToken } from '../auth/index.js';
|
|
10
4
|
export function registerSubscriptionsTools(server) {
|
|
11
5
|
server.tool('list_subscriptions', 'List FerrLabs product subscriptions for an organization (FerrVault, FerrTrack, FerrGrowth, FerrFleet)', {
|
|
12
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
|
13
7
|
}, async ({ org_slug }) => {
|
|
14
|
-
const token =
|
|
8
|
+
const token = await getToken();
|
|
15
9
|
const subs = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/subscriptions`, { token });
|
|
16
10
|
return {
|
|
17
11
|
content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/tools/tokens.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/tools/tokens.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA4BpE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,QA4EnD"}
|
package/dist/tools/tokens.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { apiRequest } from './api-client.js';
|
|
3
|
-
|
|
4
|
-
function requireToken() {
|
|
5
|
-
if (!API_TOKEN) {
|
|
6
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required for authenticated operations (FERRFLOW_API_TOKEN is accepted as a fallback for backward compatibility).');
|
|
7
|
-
}
|
|
8
|
-
return API_TOKEN;
|
|
9
|
-
}
|
|
3
|
+
import { getToken } from '../auth/index.js';
|
|
10
4
|
export function registerTokenTools(server) {
|
|
11
5
|
server.tool('get_me', 'Get the current authenticated FerrLabs user profile', {}, async () => {
|
|
12
|
-
const token =
|
|
6
|
+
const token = await getToken();
|
|
13
7
|
const user = await apiRequest('/v1/auth/me', { token });
|
|
14
8
|
return {
|
|
15
9
|
content: [
|
|
@@ -21,7 +15,7 @@ export function registerTokenTools(server) {
|
|
|
21
15
|
};
|
|
22
16
|
});
|
|
23
17
|
server.tool('list_tokens', 'List all API tokens for the authenticated user', {}, async () => {
|
|
24
|
-
const token =
|
|
18
|
+
const token = await getToken();
|
|
25
19
|
const tokens = await apiRequest('/v1/auth/tokens', { token });
|
|
26
20
|
return {
|
|
27
21
|
content: [
|
|
@@ -37,7 +31,7 @@ export function registerTokenTools(server) {
|
|
|
37
31
|
scopes: z.array(z.string()).describe('Token scopes (e.g. ["*"] for all)'),
|
|
38
32
|
expires_at: z.string().optional().describe('Expiration date (ISO 8601)'),
|
|
39
33
|
}, async ({ name, scopes, expires_at }) => {
|
|
40
|
-
const token =
|
|
34
|
+
const token = await getToken();
|
|
41
35
|
const result = await apiRequest('/v1/auth/tokens', {
|
|
42
36
|
method: 'POST',
|
|
43
37
|
body: { name, scopes, expires_at },
|
|
@@ -56,7 +50,7 @@ export function registerTokenTools(server) {
|
|
|
56
50
|
server.tool('revoke_token', 'Revoke a FerrLabs API token by ID', {
|
|
57
51
|
token_id: z.string().uuid().describe('Token ID to revoke'),
|
|
58
52
|
}, async ({ token_id }) => {
|
|
59
|
-
const token =
|
|
53
|
+
const token = await getToken();
|
|
60
54
|
await apiRequest(`/v1/auth/tokens/${token_id}`, {
|
|
61
55
|
method: 'DELETE',
|
|
62
56
|
token,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vaults.d.ts","sourceRoot":"","sources":["../../src/tools/vaults.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"vaults.d.ts","sourceRoot":"","sources":["../../src/tools/vaults.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAapE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,QAmBpD"}
|
package/dist/tools/vaults.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { apiRequest } from './api-client.js';
|
|
3
|
-
|
|
4
|
-
function requireToken() {
|
|
5
|
-
if (!API_TOKEN) {
|
|
6
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required for authenticated operations.');
|
|
7
|
-
}
|
|
8
|
-
return API_TOKEN;
|
|
9
|
-
}
|
|
3
|
+
import { getToken } from '../auth/index.js';
|
|
10
4
|
export function registerVaultsTools(server) {
|
|
11
5
|
server.tool('list_vaults', 'List FerrVault vaults inside a project (FerrVault — secrets management)', {
|
|
12
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
|
13
7
|
project_slug: z.string().min(1).describe('Project slug'),
|
|
14
8
|
}, async ({ org_slug, project_slug }) => {
|
|
15
|
-
const token =
|
|
9
|
+
const token = await getToken();
|
|
16
10
|
const vaults = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/projects/${encodeURIComponent(project_slug)}/vaults`, { token });
|
|
17
11
|
return {
|
|
18
12
|
content: [{ type: 'text', text: JSON.stringify(vaults, null, 2) }],
|