@ferrlabs/mcp 5.2.4 → 6.0.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/index.js +21 -16
- package/dist/tools/issues.js +2 -2
- package/dist/tools/orgs.js +2 -2
- package/dist/tools/stats.js +1 -1
- package/dist/tools/subscriptions.js +2 -2
- package/dist/tools/tokens.js +2 -2
- package/dist/tools/vaults.js +2 -2
- package/package.json +16 -41
- package/README.md +0 -119
- package/dist/auth/__tests__/context.test.d.ts +0 -2
- package/dist/auth/__tests__/context.test.d.ts.map +0 -1
- package/dist/auth/__tests__/context.test.js +0 -29
- package/dist/auth/__tests__/index.test.d.ts +0 -2
- package/dist/auth/__tests__/index.test.d.ts.map +0 -1
- package/dist/auth/__tests__/index.test.js +0 -39
- package/dist/auth/__tests__/oauth.test.d.ts +0 -2
- package/dist/auth/__tests__/oauth.test.d.ts.map +0 -1
- package/dist/auth/__tests__/oauth.test.js +0 -26
- package/dist/auth/context.d.ts +0 -9
- package/dist/auth/context.d.ts.map +0 -1
- package/dist/auth/context.js +0 -8
- package/dist/auth/index.d.ts +0 -3
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -43
- package/dist/auth/oauth.d.ts +0 -12
- package/dist/auth/oauth.d.ts.map +0 -1
- package/dist/auth/oauth.js +0 -123
- package/dist/auth/persistence.d.ts +0 -4
- package/dist/auth/persistence.d.ts.map +0 -1
- package/dist/auth/persistence.js +0 -61
- package/dist/server.d.ts +0 -3
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -20
- package/dist/tools/__tests__/api-client.test.d.ts +0 -2
- package/dist/tools/__tests__/api-client.test.d.ts.map +0 -1
- package/dist/tools/__tests__/api-client.test.js +0 -49
- package/dist/tools/api-client.d.ts +0 -8
- package/dist/tools/api-client.d.ts.map +0 -1
- package/dist/tools/api-client.js +0 -37
- package/dist/transports/http.d.ts +0 -7
- package/dist/transports/http.d.ts.map +0 -1
- package/dist/transports/http.js +0 -89
- package/dist/transports/stdio.d.ts +0 -2
- package/dist/transports/stdio.d.ts.map +0 -1
- package/dist/transports/stdio.js +0 -7
package/dist/index.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
2
|
+
import { runMcp } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { registerStatsTools } from './tools/stats.js';
|
|
4
|
+
import { registerTokenTools } from './tools/tokens.js';
|
|
5
|
+
import { registerOrgsTools } from './tools/orgs.js';
|
|
6
|
+
import { registerVaultsTools } from './tools/vaults.js';
|
|
7
|
+
import { registerIssuesTools } from './tools/issues.js';
|
|
8
|
+
import { registerSubscriptionsTools } from './tools/subscriptions.js';
|
|
9
|
+
import { registerDocsTools } from './tools/docs.js';
|
|
10
|
+
runMcp({
|
|
11
|
+
name: 'ferrlabs',
|
|
12
|
+
version: '5.2.5',
|
|
13
|
+
register: (server) => {
|
|
14
|
+
registerStatsTools(server);
|
|
15
|
+
registerTokenTools(server);
|
|
16
|
+
registerOrgsTools(server);
|
|
17
|
+
registerVaultsTools(server);
|
|
18
|
+
registerIssuesTools(server);
|
|
19
|
+
registerSubscriptionsTools(server);
|
|
20
|
+
registerDocsTools(server);
|
|
21
|
+
},
|
|
22
|
+
}).catch((err) => {
|
|
18
23
|
console.error('ferrlabs-mcp fatal:', err instanceof Error ? err.message : err);
|
|
19
24
|
process.exit(1);
|
|
20
25
|
});
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { apiRequest } from '
|
|
3
|
-
import { getToken } from '
|
|
2
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { getToken } from '@ferrlabs/mcp-core';
|
|
4
4
|
export function registerIssuesTools(server) {
|
|
5
5
|
server.tool('list_issues', 'List FerrTrack issues for a project (FerrTrack — issue tracker)', {
|
|
6
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
package/dist/tools/orgs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { apiRequest } from '
|
|
3
|
-
import { getToken } from '
|
|
2
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { getToken } from '@ferrlabs/mcp-core';
|
|
4
4
|
export function registerOrgsTools(server) {
|
|
5
5
|
server.tool('list_orgs', 'List FerrLabs organizations the authenticated user belongs to', {}, async () => {
|
|
6
6
|
const token = await getToken();
|
package/dist/tools/stats.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { apiRequest } from '
|
|
1
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
2
2
|
export function registerStatsTools(server) {
|
|
3
3
|
server.tool('get_stats', 'Get public FerrLabs usage statistics (releases cut, repos using FerrFlow, commits analyzed)', {}, async () => {
|
|
4
4
|
const stats = await apiRequest('/stats');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { apiRequest } from '
|
|
3
|
-
import { getToken } from '
|
|
2
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { getToken } from '@ferrlabs/mcp-core';
|
|
4
4
|
export function registerSubscriptionsTools(server) {
|
|
5
5
|
server.tool('list_subscriptions', 'List FerrLabs product subscriptions for an organization (FerrVault, FerrTrack, FerrGrowth, FerrFleet)', {
|
|
6
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
package/dist/tools/tokens.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { apiRequest } from '
|
|
3
|
-
import { getToken } from '
|
|
2
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { getToken } from '@ferrlabs/mcp-core';
|
|
4
4
|
export function registerTokenTools(server) {
|
|
5
5
|
server.tool('get_me', 'Get the current authenticated FerrLabs user profile', {}, async () => {
|
|
6
6
|
const token = await getToken();
|
package/dist/tools/vaults.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { apiRequest } from '
|
|
3
|
-
import { getToken } from '
|
|
2
|
+
import { apiRequest } from '@ferrlabs/mcp-core';
|
|
3
|
+
import { getToken } from '@ferrlabs/mcp-core';
|
|
4
4
|
export function registerVaultsTools(server) {
|
|
5
5
|
server.tool('list_vaults', 'List FerrVault vaults inside a project (FerrVault — secrets management)', {
|
|
6
6
|
org_slug: z.string().min(1).describe('Organization slug'),
|
package/package.json
CHANGED
|
@@ -1,66 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
+
"name": "@ferrlabs/mcp",
|
|
3
|
+
"version": "6.0.0",
|
|
4
|
+
"description": "Model Context Protocol server for the FerrLabs platform (orgs, projects, subscriptions, docs, tokens, public stats)",
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"type": "module",
|
|
2
7
|
"bin": {
|
|
3
8
|
"ferrlabs-mcp": "./dist/index.js"
|
|
4
9
|
},
|
|
5
|
-
"
|
|
6
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
7
|
-
"zod": "^4.3.6"
|
|
8
|
-
},
|
|
9
|
-
"description": "Model Context Protocol server for the FerrLabs ecosystem (FerrFlow, FerrVault, FerrTrack, FerrGrowth, FerrFleet, FerrLens)",
|
|
10
|
-
"devDependencies": {
|
|
11
|
-
"@types/node": "^25.5.0",
|
|
12
|
-
"@vitest/coverage-v8": "^4.1.2",
|
|
13
|
-
"husky": "^9.1.7",
|
|
14
|
-
"lint-staged": "^15.2.10",
|
|
15
|
-
"prettier": "^3.4.2",
|
|
16
|
-
"tsx": "^4.19.0",
|
|
17
|
-
"typescript": "^6.0.0",
|
|
18
|
-
"vitest": "^4.1.2"
|
|
19
|
-
},
|
|
20
|
-
"lint-staged": {
|
|
21
|
-
"*.{ts,tsx,js,jsx,mjs,cjs,json,md,css}": "prettier --write"
|
|
22
|
-
},
|
|
23
|
-
"engines": {
|
|
24
|
-
"node": ">=22.0.0",
|
|
25
|
-
"pnpm": ">=9.0.0"
|
|
26
|
-
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
27
11
|
"files": [
|
|
28
12
|
"dist"
|
|
29
13
|
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.0.0"
|
|
16
|
+
},
|
|
30
17
|
"keywords": [
|
|
31
18
|
"mcp",
|
|
32
19
|
"ferrlabs",
|
|
33
|
-
"ferrflow",
|
|
34
|
-
"ferrvault",
|
|
35
|
-
"ferrtrack",
|
|
36
|
-
"ferrgrowth",
|
|
37
|
-
"ferrfleet",
|
|
38
|
-
"ferrlens",
|
|
39
20
|
"model-context-protocol",
|
|
40
21
|
"ai"
|
|
41
22
|
],
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
|
+
"zod": "^4.3.6",
|
|
26
|
+
"@ferrlabs/mcp-core": "^6.0.0"
|
|
27
|
+
},
|
|
45
28
|
"publishConfig": {
|
|
46
29
|
"access": "public"
|
|
47
30
|
},
|
|
48
31
|
"repository": {
|
|
49
32
|
"type": "git",
|
|
50
|
-
"url": "https://github.com/FerrLabs/MCP.git"
|
|
33
|
+
"url": "https://github.com/FerrLabs/MCP.git",
|
|
34
|
+
"directory": "packages/mcp"
|
|
51
35
|
},
|
|
52
|
-
"type": "module",
|
|
53
|
-
"version": "5.2.4",
|
|
54
36
|
"scripts": {
|
|
55
37
|
"build": "tsc",
|
|
56
|
-
"ci:local": "pnpm typecheck && pnpm test && pnpm build",
|
|
57
|
-
"dev": "tsx watch src/index.ts",
|
|
58
|
-
"format": "prettier --write .",
|
|
59
|
-
"format:check": "prettier --check .",
|
|
60
|
-
"smoke": "pnpm build && tsx scripts/smoke.ts",
|
|
61
38
|
"start": "node dist/index.js",
|
|
62
|
-
"test": "vitest run",
|
|
63
|
-
"test:coverage": "vitest run --coverage",
|
|
64
39
|
"typecheck": "tsc --noEmit"
|
|
65
40
|
}
|
|
66
41
|
}
|
package/README.md
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# FerrLabs MCP Server
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@ferrlabs/mcp)
|
|
4
|
-
[](https://codecov.io/gh/FerrLabs/MCP)
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
|
|
7
|
-
[Model Context Protocol](https://modelcontextprotocol.io) server that lets AI assistants interact with the FerrLabs ecosystem (FerrFlow, FerrVault, FerrTrack, FerrGrowth, FerrFleet, FerrLens) through the unified FerrLabs API. Runs locally via stdio transport.
|
|
8
|
-
|
|
9
|
-
## Quick Start
|
|
10
|
-
|
|
11
|
-
Add to your MCP client configuration (Claude Code, Cursor, etc.):
|
|
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
|
-
|
|
30
|
-
```json
|
|
31
|
-
{
|
|
32
|
-
"mcpServers": {
|
|
33
|
-
"ferrlabs": {
|
|
34
|
-
"command": "npx",
|
|
35
|
-
"args": ["-y", "@ferrlabs/mcp"],
|
|
36
|
-
"env": { "FERRLABS_API_TOKEN": "fl_..." }
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Create the token from `app.ferrlabs.com` → Settings → API Tokens. It's forwarded as `x-api-token` to `api.ferrlabs.com`.
|
|
43
|
-
|
|
44
|
-
## Available Tools
|
|
45
|
-
|
|
46
|
-
| Tool | Scope | Description |
|
|
47
|
-
| -------------------- | ------- | ------------------------------------------------------------------------------ |
|
|
48
|
-
| `get_stats` | public | Public usage statistics from `api.ferrlabs.com` |
|
|
49
|
-
| `health_check` | public | API health status |
|
|
50
|
-
| `fetch_docs` | public | Fetch a page from a product marketing site (ferrflow, ferrvault, ferrtrack, …) |
|
|
51
|
-
| `get_me` | auth | Current user profile |
|
|
52
|
-
| `list_tokens` | auth | List your API tokens |
|
|
53
|
-
| `create_token` | session | Create a new API token (requires interactive session, not a token) |
|
|
54
|
-
| `revoke_token` | auth | Revoke an API token |
|
|
55
|
-
| `list_orgs` | auth | List FerrLabs organizations you belong to |
|
|
56
|
-
| `list_projects` | auth | List projects inside an organization |
|
|
57
|
-
| `list_subscriptions` | auth | List product subscriptions for an organization |
|
|
58
|
-
| `list_vaults` | auth | List FerrVault vaults inside a project |
|
|
59
|
-
| `list_issues` | auth | List FerrTrack issues inside a project |
|
|
60
|
-
|
|
61
|
-
FerrFlow CLI-specific tools (`dry_run`, `validate_config`, `read_config`, `read_changelog`, `list_release_tags`, `record_event`) were removed in v4.0.0 — they required either a local FerrFlow CLI install or HMAC signing the MCP doesn't do. Use the FerrFlow CLI directly or fetch docs via `fetch_docs("ferrflow", "docs/...")`.
|
|
62
|
-
|
|
63
|
-
## Stack
|
|
64
|
-
|
|
65
|
-
| Component | Technology |
|
|
66
|
-
| --------- | ------------------------- |
|
|
67
|
-
| Runtime | Node.js 22+ |
|
|
68
|
-
| Language | TypeScript |
|
|
69
|
-
| MCP SDK | @modelcontextprotocol/sdk |
|
|
70
|
-
|
|
71
|
-
## Configuration
|
|
72
|
-
|
|
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.
|
|
93
|
-
|
|
94
|
-
## Smoke test
|
|
95
|
-
|
|
96
|
-
`pnpm smoke` builds the server, spawns it over stdio, runs the MCP `initialize`/`tools/list` handshake, then calls `health_check` and `get_stats` against the real `api.ferrlabs.com`. Useful to confirm the server boots, registers all tools, and the API is reachable. Exits non-zero on any failure.
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
[PASS] initialize handshake — serverInfo: ferrlabs@4.0.0
|
|
100
|
-
[PASS] tools/list — 12 tools registered
|
|
101
|
-
[PASS] tools/call health_check — {"status":"ready", ...}
|
|
102
|
-
[PASS] tools/call get_stats — total_releases=N
|
|
103
|
-
|
|
104
|
-
Smoke: 4/4 OK
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
## Migrating from `@ferrflow/mcp` v3.x
|
|
108
|
-
|
|
109
|
-
v4.0.0 renames the package and points the MCP at the unified FerrLabs API. To migrate:
|
|
110
|
-
|
|
111
|
-
1. Replace `@ferrflow/mcp` with `@ferrlabs/mcp` in your MCP client config.
|
|
112
|
-
2. Rename `FERRFLOW_API_TOKEN` → `FERRLABS_API_TOKEN` (the old name still works for one release).
|
|
113
|
-
3. If you were overriding `API_URL=https://api.ferrflow.com`, drop the override — the default is now `https://api.ferrlabs.com`.
|
|
114
|
-
|
|
115
|
-
Tokens issued by the legacy FerrFlow-only API are not valid against the unified API. Create a fresh one from `app.ferrlabs.com`.
|
|
116
|
-
|
|
117
|
-
## License
|
|
118
|
-
|
|
119
|
-
Mozilla Public License 2.0 — see [LICENSE](LICENSE).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"context.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/context.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
describe('per-request bearer token takes precedence over env/persisted', () => {
|
|
3
|
-
beforeEach(() => {
|
|
4
|
-
delete process.env.FERRLABS_API_TOKEN;
|
|
5
|
-
delete process.env.FERRFLOW_API_TOKEN;
|
|
6
|
-
process.env.FERRLABS_MCP_NO_PERSIST = '1';
|
|
7
|
-
process.env.FERRLABS_MCP_NO_OAUTH = '1';
|
|
8
|
-
});
|
|
9
|
-
it('returns the bearer from AuthContext when set', async () => {
|
|
10
|
-
const { runWithAuthContext } = await import('../context.js');
|
|
11
|
-
const { getToken } = await import('../index.js');
|
|
12
|
-
const result = await runWithAuthContext({ bearerToken: 'fl_req_token' }, () => getToken());
|
|
13
|
-
expect(result).toBe('fl_req_token');
|
|
14
|
-
});
|
|
15
|
-
it('bearer from context wins over FERRLABS_API_TOKEN env', async () => {
|
|
16
|
-
process.env.FERRLABS_API_TOKEN = 'fl_env';
|
|
17
|
-
const { runWithAuthContext } = await import('../context.js');
|
|
18
|
-
const { getToken } = await import('../index.js');
|
|
19
|
-
const result = await runWithAuthContext({ bearerToken: 'fl_req' }, () => getToken());
|
|
20
|
-
expect(result).toBe('fl_req');
|
|
21
|
-
});
|
|
22
|
-
it('falls back to env when context has no bearer', async () => {
|
|
23
|
-
process.env.FERRLABS_API_TOKEN = 'fl_env_only';
|
|
24
|
-
const { runWithAuthContext } = await import('../context.js');
|
|
25
|
-
const { getToken } = await import('../index.js');
|
|
26
|
-
const result = await runWithAuthContext({}, () => getToken());
|
|
27
|
-
expect(result).toBe('fl_env_only');
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/oauth.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
-
});
|
package/dist/auth/context.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
interface AuthContext {
|
|
3
|
-
bearerToken?: string;
|
|
4
|
-
}
|
|
5
|
-
export declare const authContext: AsyncLocalStorage<AuthContext>;
|
|
6
|
-
export declare function runWithAuthContext<T>(ctx: AuthContext, fn: () => Promise<T>): Promise<T>;
|
|
7
|
-
export declare function getRequestBearerToken(): string | undefined;
|
|
8
|
-
export {};
|
|
9
|
-
//# sourceMappingURL=context.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,UAAU,WAAW;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,WAAW,gCAAuC,CAAC;AAEhE,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAExF;AAED,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,SAAS,CAE1D"}
|
package/dist/auth/context.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
export const authContext = new AsyncLocalStorage();
|
|
3
|
-
export function runWithAuthContext(ctx, fn) {
|
|
4
|
-
return authContext.run(ctx, fn);
|
|
5
|
-
}
|
|
6
|
-
export function getRequestBearerToken() {
|
|
7
|
-
return authContext.getStore()?.bearerToken;
|
|
8
|
-
}
|
package/dist/auth/index.d.ts
DELETED
package/dist/auth/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,yBAAyB,IAAI,IAAI,CAGhD"}
|
package/dist/auth/index.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { readPersistedToken, writePersistedToken } from './persistence.js';
|
|
2
|
-
import { runLoopbackOauthFlow } from './oauth.js';
|
|
3
|
-
import { getRequestBearerToken } from './context.js';
|
|
4
|
-
let cached = null;
|
|
5
|
-
let inFlight = null;
|
|
6
|
-
export async function getToken() {
|
|
7
|
-
const perRequest = getRequestBearerToken();
|
|
8
|
-
if (perRequest)
|
|
9
|
-
return perRequest;
|
|
10
|
-
if (process.env.FERRLABS_MCP_MODE === 'http') {
|
|
11
|
-
throw new Error('Bearer token required. Send `Authorization: Bearer fl_...` on each request.');
|
|
12
|
-
}
|
|
13
|
-
if (cached)
|
|
14
|
-
return cached;
|
|
15
|
-
const envToken = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
|
|
16
|
-
if (envToken) {
|
|
17
|
-
cached = envToken;
|
|
18
|
-
return envToken;
|
|
19
|
-
}
|
|
20
|
-
const persisted = await readPersistedToken();
|
|
21
|
-
if (persisted) {
|
|
22
|
-
cached = persisted;
|
|
23
|
-
return persisted;
|
|
24
|
-
}
|
|
25
|
-
if (process.env.FERRLABS_MCP_NO_OAUTH === '1') {
|
|
26
|
-
throw new Error('FERRLABS_API_TOKEN environment variable is required (FERRLABS_MCP_NO_OAUTH=1 disables the OAuth fallback).');
|
|
27
|
-
}
|
|
28
|
-
if (!inFlight) {
|
|
29
|
-
inFlight = (async () => {
|
|
30
|
-
const { token } = await runLoopbackOauthFlow();
|
|
31
|
-
await writePersistedToken(token);
|
|
32
|
-
cached = token;
|
|
33
|
-
return token;
|
|
34
|
-
})().finally(() => {
|
|
35
|
-
inFlight = null;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
return inFlight;
|
|
39
|
-
}
|
|
40
|
-
export function resetTokenCacheForTesting() {
|
|
41
|
-
cached = null;
|
|
42
|
-
inFlight = null;
|
|
43
|
-
}
|
package/dist/auth/oauth.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
package/dist/auth/oauth.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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"}
|
package/dist/auth/oauth.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
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"}
|
package/dist/auth/persistence.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/server.d.ts
DELETED
package/dist/server.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAWpE,wBAAgB,eAAe,IAAI,SAAS,CAU3C"}
|
package/dist/server.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { registerStatsTools } from './tools/stats.js';
|
|
3
|
-
import { registerTokenTools } from './tools/tokens.js';
|
|
4
|
-
import { registerOrgsTools } from './tools/orgs.js';
|
|
5
|
-
import { registerVaultsTools } from './tools/vaults.js';
|
|
6
|
-
import { registerIssuesTools } from './tools/issues.js';
|
|
7
|
-
import { registerSubscriptionsTools } from './tools/subscriptions.js';
|
|
8
|
-
import { registerDocsTools } from './tools/docs.js';
|
|
9
|
-
const VERSION = '5.1.1';
|
|
10
|
-
export function createMcpServer() {
|
|
11
|
-
const server = new McpServer({ name: 'ferrlabs', version: VERSION });
|
|
12
|
-
registerStatsTools(server);
|
|
13
|
-
registerTokenTools(server);
|
|
14
|
-
registerOrgsTools(server);
|
|
15
|
-
registerVaultsTools(server);
|
|
16
|
-
registerIssuesTools(server);
|
|
17
|
-
registerSubscriptionsTools(server);
|
|
18
|
-
registerDocsTools(server);
|
|
19
|
-
return server;
|
|
20
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/api-client.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { apiRequest } from '../api-client.js';
|
|
3
|
-
const mockFetch = vi.fn();
|
|
4
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
5
|
-
function makeResponse(body, status = 200) {
|
|
6
|
-
const text = body === undefined ? '' : JSON.stringify(body);
|
|
7
|
-
return {
|
|
8
|
-
ok: status >= 200 && status < 300,
|
|
9
|
-
status,
|
|
10
|
-
text: () => Promise.resolve(text),
|
|
11
|
-
json: () => Promise.resolve(body),
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
describe('apiRequest', () => {
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
mockFetch.mockReset();
|
|
17
|
-
});
|
|
18
|
-
it('makes a GET request and returns parsed JSON', async () => {
|
|
19
|
-
mockFetch.mockResolvedValue(makeResponse({ status: 'ok' }));
|
|
20
|
-
const result = await apiRequest('/health');
|
|
21
|
-
expect(result).toEqual({ status: 'ok' });
|
|
22
|
-
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/health'), expect.objectContaining({ method: 'GET' }));
|
|
23
|
-
});
|
|
24
|
-
it('includes x-api-token header when token is provided', async () => {
|
|
25
|
-
mockFetch.mockResolvedValue(makeResponse({ id: 'user-1' }));
|
|
26
|
-
await apiRequest('/v1/auth/me', { token: 'my-secret-token' });
|
|
27
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
28
|
-
expect(init.headers['x-api-token']).toBe('my-secret-token');
|
|
29
|
-
});
|
|
30
|
-
it('returns undefined for 204 No Content responses', async () => {
|
|
31
|
-
mockFetch.mockResolvedValue({ ok: true, status: 204, json: vi.fn() });
|
|
32
|
-
const result = await apiRequest('/v1/auth/tokens/123', { method: 'DELETE' });
|
|
33
|
-
expect(result).toBeUndefined();
|
|
34
|
-
});
|
|
35
|
-
it('throws an error when the response is not ok', async () => {
|
|
36
|
-
mockFetch.mockResolvedValue(makeResponse({ error: 'Not found' }, 404));
|
|
37
|
-
await expect(apiRequest('/missing')).rejects.toThrow('Not found');
|
|
38
|
-
});
|
|
39
|
-
it('throws a generic error when response has no error field', async () => {
|
|
40
|
-
mockFetch.mockResolvedValue(makeResponse({}, 500));
|
|
41
|
-
await expect(apiRequest('/broken')).rejects.toThrow('API error: HTTP 500');
|
|
42
|
-
});
|
|
43
|
-
it('sends a JSON body for POST requests', async () => {
|
|
44
|
-
mockFetch.mockResolvedValue(makeResponse({ id: 'tok-1' }));
|
|
45
|
-
await apiRequest('/v1/auth/tokens', { method: 'POST', body: { name: 'ci', scopes: ['*'] } });
|
|
46
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
47
|
-
expect(init.body).toBe(JSON.stringify({ name: 'ci', scopes: ['*'] }));
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../../src/tools/api-client.ts"],"names":[],"mappings":"AAEA,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC,CA0C1F"}
|
package/dist/tools/api-client.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
const API_URL = process.env.API_URL ?? 'https://api.ferrlabs.com';
|
|
2
|
-
export async function apiRequest(path, options = {}) {
|
|
3
|
-
const { method = 'GET', body, token } = options;
|
|
4
|
-
const headers = {
|
|
5
|
-
'Content-Type': 'application/json',
|
|
6
|
-
'User-Agent': 'ferrlabs-mcp/4.0.0',
|
|
7
|
-
};
|
|
8
|
-
if (token) {
|
|
9
|
-
headers['x-api-token'] = token;
|
|
10
|
-
headers['Authorization'] = `Bearer ${token}`;
|
|
11
|
-
}
|
|
12
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
13
|
-
method,
|
|
14
|
-
headers,
|
|
15
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
16
|
-
});
|
|
17
|
-
if (res.status === 204) {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
const raw = await res.text();
|
|
21
|
-
let data = undefined;
|
|
22
|
-
if (raw) {
|
|
23
|
-
try {
|
|
24
|
-
data = JSON.parse(raw);
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
throw new Error(`API non-JSON response (HTTP ${res.status}): ${raw.slice(0, 200)}`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (!res.ok) {
|
|
31
|
-
const errMsg = data?.error ??
|
|
32
|
-
data?.message ??
|
|
33
|
-
`API error: HTTP ${res.status}`;
|
|
34
|
-
throw new Error(errMsg);
|
|
35
|
-
}
|
|
36
|
-
return data;
|
|
37
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0D5E"}
|
package/dist/transports/http.js
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { createServer } from 'node:http';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
-
import { createMcpServer } from '../server.js';
|
|
5
|
-
import { runWithAuthContext } from '../auth/context.js';
|
|
6
|
-
function extractBearer(req) {
|
|
7
|
-
const header = req.headers['authorization'];
|
|
8
|
-
if (typeof header !== 'string')
|
|
9
|
-
return undefined;
|
|
10
|
-
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
11
|
-
return match?.[1];
|
|
12
|
-
}
|
|
13
|
-
async function readJsonBody(req) {
|
|
14
|
-
if (req.method !== 'POST')
|
|
15
|
-
return undefined;
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
const chunks = [];
|
|
18
|
-
req.on('data', (c) => chunks.push(c));
|
|
19
|
-
req.on('end', () => {
|
|
20
|
-
const raw = Buffer.concat(chunks).toString('utf8');
|
|
21
|
-
if (!raw)
|
|
22
|
-
return resolve(undefined);
|
|
23
|
-
try {
|
|
24
|
-
resolve(JSON.parse(raw));
|
|
25
|
-
}
|
|
26
|
-
catch (err) {
|
|
27
|
-
reject(err);
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
req.on('error', reject);
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
export async function startHttpServer(opts) {
|
|
34
|
-
const { port, host = '0.0.0.0', stateless = true } = opts;
|
|
35
|
-
const transports = new Map();
|
|
36
|
-
async function handleMcpRequest(req, res) {
|
|
37
|
-
const sessionId = req.headers['mcp-session-id'] ?? undefined;
|
|
38
|
-
let transport = sessionId ? transports.get(sessionId) : undefined;
|
|
39
|
-
if (!transport) {
|
|
40
|
-
transport = new StreamableHTTPServerTransport({
|
|
41
|
-
sessionIdGenerator: stateless ? undefined : randomUUID,
|
|
42
|
-
onsessioninitialized: stateless
|
|
43
|
-
? undefined
|
|
44
|
-
: (newSessionId) => {
|
|
45
|
-
if (transport)
|
|
46
|
-
transports.set(newSessionId, transport);
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
transport.onclose = () => {
|
|
50
|
-
if (sessionId)
|
|
51
|
-
transports.delete(sessionId);
|
|
52
|
-
};
|
|
53
|
-
const server = createMcpServer();
|
|
54
|
-
await server.connect(transport);
|
|
55
|
-
}
|
|
56
|
-
const body = await readJsonBody(req);
|
|
57
|
-
const bearerToken = extractBearer(req);
|
|
58
|
-
await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
|
|
59
|
-
}
|
|
60
|
-
const httpServer = createServer((req, res) => {
|
|
61
|
-
const url = req.url ?? '/';
|
|
62
|
-
if (url === '/health' || url === '/livez' || url === '/readyz') {
|
|
63
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
64
|
-
res.end(JSON.stringify({ status: 'ok', service: 'ferrlabs-mcp' }));
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (url === '/mcp' || url.startsWith('/mcp?')) {
|
|
68
|
-
handleMcpRequest(req, res).catch((err) => {
|
|
69
|
-
if (!res.headersSent) {
|
|
70
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
71
|
-
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
res.end();
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
80
|
-
res.end(JSON.stringify({ error: 'Not found' }));
|
|
81
|
-
});
|
|
82
|
-
await new Promise((resolve, reject) => {
|
|
83
|
-
httpServer.once('error', reject);
|
|
84
|
-
httpServer.listen(port, host, () => {
|
|
85
|
-
console.error(`ferrlabs-mcp HTTP transport listening on http://${host}:${port}/mcp`);
|
|
86
|
-
resolve();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../../src/transports/stdio.ts"],"names":[],"mappings":"AAGA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
|
package/dist/transports/stdio.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
2
|
-
import { createMcpServer } from '../server.js';
|
|
3
|
-
export async function startStdioServer() {
|
|
4
|
-
const server = createMcpServer();
|
|
5
|
-
const transport = new StdioServerTransport();
|
|
6
|
-
await server.connect(transport);
|
|
7
|
-
}
|