@ferrlabs/mcp 5.0.0 → 5.1.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/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 a token from `app.ferrlabs.com` (Settings → API Tokens) and assign the scopes you want the assistant to have. The token is forwarded as `x-api-token` to `api.ferrlabs.com`.
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 | Description | Default |
59
- | -------------------- | ---------------------------------------------------------------------------- | -------------------------- |
60
- | `API_URL` | FerrLabs API base URL (no `/v1` suffix — paths are prefixed in code) | `https://api.ferrlabs.com` |
61
- | `FERRLABS_API_TOKEN` | FerrLabs API token (required for authenticated tools) | — |
62
- | `FERRFLOW_API_TOKEN` | **Deprecated** — accepted as a fallback for backward compatibility with v3.x | — |
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=oauth.test.d.ts.map
@@ -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,3 @@
1
+ export declare function getToken(): Promise<string>;
2
+ export declare function resetTokenCacheForTesting(): void;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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: 'cmd', args: ['/c', 'start', '""', 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,4 @@
1
+ export declare function readPersistedToken(): Promise<string | null>;
2
+ export declare function writePersistedToken(token: string, scopes?: string[]): Promise<void>;
3
+ export declare function clearPersistedToken(): Promise<void>;
4
+ //# sourceMappingURL=persistence.d.ts.map
@@ -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;AAgCpE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,QA+BpD"}
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"}
@@ -1,12 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest } from './api-client.js';
3
- const API_TOKEN = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
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 = requireToken();
17
+ const token = await getToken();
24
18
  const params = new URLSearchParams();
25
19
  if (page !== undefined)
26
20
  params.set('page', String(page));
@@ -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;AA+BpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,QA+BlD"}
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"}
@@ -1,15 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest } from './api-client.js';
3
- const API_TOKEN = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
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 = requireToken();
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 = requireToken();
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;AA0BpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,SAAS,QAkB3D"}
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
- const API_TOKEN = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
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 = requireToken();
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;AAsCpE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,QA4EnD"}
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"}
@@ -1,15 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest } from './api-client.js';
3
- const API_TOKEN = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
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 = requireToken();
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 = requireToken();
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 = requireToken();
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 = requireToken();
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;AAuBpE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,QAmBpD"}
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"}
@@ -1,18 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest } from './api-client.js';
3
- const API_TOKEN = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
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 = requireToken();
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) }],
package/package.json CHANGED
@@ -50,7 +50,7 @@
50
50
  "url": "https://github.com/FerrLabs/MCP.git"
51
51
  },
52
52
  "type": "module",
53
- "version": "5.0.0",
53
+ "version": "5.1.0",
54
54
  "scripts": {
55
55
  "build": "tsc",
56
56
  "ci:local": "pnpm typecheck && pnpm test && pnpm build",