@ferrlabs/mcp 4.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/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/tools/__tests__/api-client.test.d.ts +2 -0
- package/dist/tools/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/tools/__tests__/api-client.test.js +47 -0
- package/dist/tools/__tests__/orgs.test.d.ts +2 -0
- package/dist/tools/__tests__/orgs.test.d.ts.map +1 -0
- package/dist/tools/__tests__/orgs.test.js +51 -0
- package/dist/tools/api-client.d.ts +8 -0
- package/dist/tools/api-client.d.ts.map +1 -0
- package/dist/tools/api-client.js +24 -0
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.d.ts.map +1 -0
- package/dist/tools/docs.js +62 -0
- package/dist/tools/issues.d.ts +3 -0
- package/dist/tools/issues.d.ts.map +1 -0
- package/dist/tools/issues.js +35 -0
- package/dist/tools/orgs.d.ts +3 -0
- package/dist/tools/orgs.d.ts.map +1 -0
- package/dist/tools/orgs.js +27 -0
- package/dist/tools/stats.d.ts +3 -0
- package/dist/tools/stats.d.ts.map +1 -0
- package/dist/tools/stats.js +25 -0
- package/dist/tools/subscriptions.d.ts +3 -0
- package/dist/tools/subscriptions.d.ts.map +1 -0
- package/dist/tools/subscriptions.js +20 -0
- package/dist/tools/tokens.d.ts +3 -0
- package/dist/tools/tokens.d.ts.map +1 -0
- package/dist/tools/tokens.js +73 -0
- package/dist/tools/vaults.d.ts +3 -0
- package/dist/tools/vaults.d.ts.map +1 -0
- package/dist/tools/vaults.js +21 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2024-present FerrLabs Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
"env": {
|
|
20
|
+
"FERRLABS_API_TOKEN": "fl_..."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
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`.
|
|
28
|
+
|
|
29
|
+
## Available Tools
|
|
30
|
+
|
|
31
|
+
| Tool | Scope | Description |
|
|
32
|
+
| -------------------- | ------- | ------------------------------------------------------------------------------ |
|
|
33
|
+
| `get_stats` | public | Public usage statistics from `api.ferrlabs.com` |
|
|
34
|
+
| `health_check` | public | API health status |
|
|
35
|
+
| `fetch_docs` | public | Fetch a page from a product marketing site (ferrflow, ferrvault, ferrtrack, …) |
|
|
36
|
+
| `get_me` | auth | Current user profile |
|
|
37
|
+
| `list_tokens` | auth | List your API tokens |
|
|
38
|
+
| `create_token` | session | Create a new API token (requires interactive session, not a token) |
|
|
39
|
+
| `revoke_token` | auth | Revoke an API token |
|
|
40
|
+
| `list_orgs` | auth | List FerrLabs organizations you belong to |
|
|
41
|
+
| `list_projects` | auth | List projects inside an organization |
|
|
42
|
+
| `list_subscriptions` | auth | List product subscriptions for an organization |
|
|
43
|
+
| `list_vaults` | auth | List FerrVault vaults inside a project |
|
|
44
|
+
| `list_issues` | auth | List FerrTrack issues inside a project |
|
|
45
|
+
|
|
46
|
+
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/...")`.
|
|
47
|
+
|
|
48
|
+
## Stack
|
|
49
|
+
|
|
50
|
+
| Component | Technology |
|
|
51
|
+
| --------- | ------------------------- |
|
|
52
|
+
| Runtime | Node.js 22+ |
|
|
53
|
+
| Language | TypeScript |
|
|
54
|
+
| MCP SDK | @modelcontextprotocol/sdk |
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
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 | — |
|
|
63
|
+
|
|
64
|
+
## Smoke test
|
|
65
|
+
|
|
66
|
+
`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.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[PASS] initialize handshake — serverInfo: ferrlabs@4.0.0
|
|
70
|
+
[PASS] tools/list — 12 tools registered
|
|
71
|
+
[PASS] tools/call health_check — {"status":"ready", ...}
|
|
72
|
+
[PASS] tools/call get_stats — total_releases=N
|
|
73
|
+
|
|
74
|
+
Smoke: 4/4 OK
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Migrating from `@ferrflow/mcp` v3.x
|
|
78
|
+
|
|
79
|
+
v4.0.0 renames the package and points the MCP at the unified FerrLabs API. To migrate:
|
|
80
|
+
|
|
81
|
+
1. Replace `@ferrflow/mcp` with `@ferrlabs/mcp` in your MCP client config.
|
|
82
|
+
2. Rename `FERRFLOW_API_TOKEN` → `FERRLABS_API_TOKEN` (the old name still works for one release).
|
|
83
|
+
3. If you were overriding `API_URL=https://api.ferrflow.com`, drop the override — the default is now `https://api.ferrlabs.com`.
|
|
84
|
+
|
|
85
|
+
Tokens issued by the legacy FerrFlow-only API are not valid against the unified API. Create a fresh one from `app.ferrlabs.com`.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
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
|
+
const server = new McpServer({
|
|
11
|
+
name: 'ferrlabs',
|
|
12
|
+
version: '4.0.0',
|
|
13
|
+
});
|
|
14
|
+
registerStatsTools(server);
|
|
15
|
+
registerTokenTools(server);
|
|
16
|
+
registerOrgsTools(server);
|
|
17
|
+
registerVaultsTools(server);
|
|
18
|
+
registerIssuesTools(server);
|
|
19
|
+
registerSubscriptionsTools(server);
|
|
20
|
+
registerDocsTools(server);
|
|
21
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
await server.connect(transport);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/api-client.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
return {
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
json: () => Promise.resolve(body),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('apiRequest', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockFetch.mockReset();
|
|
15
|
+
});
|
|
16
|
+
it('makes a GET request and returns parsed JSON', async () => {
|
|
17
|
+
mockFetch.mockResolvedValue(makeResponse({ status: 'ok' }));
|
|
18
|
+
const result = await apiRequest('/health');
|
|
19
|
+
expect(result).toEqual({ status: 'ok' });
|
|
20
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/health'), expect.objectContaining({ method: 'GET' }));
|
|
21
|
+
});
|
|
22
|
+
it('includes x-api-token header when token is provided', async () => {
|
|
23
|
+
mockFetch.mockResolvedValue(makeResponse({ id: 'user-1' }));
|
|
24
|
+
await apiRequest('/v1/auth/me', { token: 'my-secret-token' });
|
|
25
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
26
|
+
expect(init.headers['x-api-token']).toBe('my-secret-token');
|
|
27
|
+
});
|
|
28
|
+
it('returns undefined for 204 No Content responses', async () => {
|
|
29
|
+
mockFetch.mockResolvedValue({ ok: true, status: 204, json: vi.fn() });
|
|
30
|
+
const result = await apiRequest('/v1/auth/tokens/123', { method: 'DELETE' });
|
|
31
|
+
expect(result).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('throws an error when the response is not ok', async () => {
|
|
34
|
+
mockFetch.mockResolvedValue(makeResponse({ error: 'Not found' }, 404));
|
|
35
|
+
await expect(apiRequest('/missing')).rejects.toThrow('Not found');
|
|
36
|
+
});
|
|
37
|
+
it('throws a generic error when response has no error field', async () => {
|
|
38
|
+
mockFetch.mockResolvedValue(makeResponse({}, 500));
|
|
39
|
+
await expect(apiRequest('/broken')).rejects.toThrow('API error: 500');
|
|
40
|
+
});
|
|
41
|
+
it('sends a JSON body for POST requests', async () => {
|
|
42
|
+
mockFetch.mockResolvedValue(makeResponse({ id: 'tok-1' }));
|
|
43
|
+
await apiRequest('/v1/auth/tokens', { method: 'POST', body: { name: 'ci', scopes: ['*'] } });
|
|
44
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
45
|
+
expect(init.body).toBe(JSON.stringify({ name: 'ci', scopes: ['*'] }));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orgs.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/orgs.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
process.env.FERRLABS_API_TOKEN = 'test-token';
|
|
3
|
+
const handlers = new Map();
|
|
4
|
+
const mockServer = {
|
|
5
|
+
tool: vi.fn((name, _desc, _schema, handler) => {
|
|
6
|
+
handlers.set(name, handler);
|
|
7
|
+
}),
|
|
8
|
+
};
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
11
|
+
function makeResponse(body, status = 200) {
|
|
12
|
+
return {
|
|
13
|
+
ok: status >= 200 && status < 300,
|
|
14
|
+
status,
|
|
15
|
+
json: () => Promise.resolve(body),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('orgs tools', () => {
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
handlers.clear();
|
|
22
|
+
const { registerOrgsTools } = await import('../orgs.js');
|
|
23
|
+
registerOrgsTools(mockServer);
|
|
24
|
+
});
|
|
25
|
+
it('registers list_orgs and list_projects', () => {
|
|
26
|
+
expect(handlers.has('list_orgs')).toBe(true);
|
|
27
|
+
expect(handlers.has('list_projects')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('list_orgs hits /v1/orgs with the API token', async () => {
|
|
30
|
+
mockFetch.mockResolvedValue(makeResponse([
|
|
31
|
+
{
|
|
32
|
+
id: 'o1',
|
|
33
|
+
slug: 'acme',
|
|
34
|
+
name: 'Acme',
|
|
35
|
+
member_count: 3,
|
|
36
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
37
|
+
},
|
|
38
|
+
]));
|
|
39
|
+
const result = await handlers.get('list_orgs')({});
|
|
40
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
41
|
+
expect(url).toContain('/v1/orgs');
|
|
42
|
+
expect(init.headers['x-api-token']).toBe('test-token');
|
|
43
|
+
expect(result).toMatchObject({ content: [{ type: 'text' }] });
|
|
44
|
+
});
|
|
45
|
+
it('list_projects URL-encodes the org slug', async () => {
|
|
46
|
+
mockFetch.mockResolvedValue(makeResponse([]));
|
|
47
|
+
await handlers.get('list_projects')({ org_slug: 'acme corp' });
|
|
48
|
+
const [url] = mockFetch.mock.calls[0];
|
|
49
|
+
expect(url).toContain('/v1/orgs/acme%20corp/projects');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
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,CA6B1F"}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
|
11
|
+
const res = await fetch(`${API_URL}${path}`, {
|
|
12
|
+
method,
|
|
13
|
+
headers,
|
|
14
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
15
|
+
});
|
|
16
|
+
if (res.status === 204) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(data.error ?? `API error: ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/tools/docs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiCpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,QA4ClD"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const PRODUCT_HOSTS = {
|
|
3
|
+
ferrlabs: 'https://ferrlabs.com',
|
|
4
|
+
ferrflow: 'https://ferrflow.com',
|
|
5
|
+
ferrvault: 'https://ferrvault.com',
|
|
6
|
+
ferrtrack: 'https://ferrtrack.com',
|
|
7
|
+
ferrgrowth: 'https://ferrgrowth.com',
|
|
8
|
+
ferrfleet: 'https://ferrfleet.com',
|
|
9
|
+
ferrlens: 'https://ferrlens.com',
|
|
10
|
+
};
|
|
11
|
+
const productEnum = z.enum(Object.keys(PRODUCT_HOSTS));
|
|
12
|
+
const MAX_BYTES = 200_000;
|
|
13
|
+
function stripHtml(html) {
|
|
14
|
+
const article = html.match(/<main[\s\S]*?<\/main>/i)?.[0] ?? html;
|
|
15
|
+
return article
|
|
16
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
17
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
18
|
+
.replace(/<[^>]+>/g, ' ')
|
|
19
|
+
.replace(/ /g, ' ')
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/[ \t]+/g, ' ')
|
|
26
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
export function registerDocsTools(server) {
|
|
30
|
+
server.tool('fetch_docs', 'Fetch documentation or marketing copy from a FerrLabs product website (ferrflow, ferrvault, ferrtrack, ferrgrowth, ferrfleet, ferrlens, or ferrlabs holding). Returns the page text content, HTML stripped.', {
|
|
31
|
+
product: productEnum.describe('FerrLabs product whose docs to fetch'),
|
|
32
|
+
slug: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Path on the product site, e.g. 'docs/getting-started' or 'pricing'. Omit for the homepage."),
|
|
36
|
+
}, async ({ product, slug }) => {
|
|
37
|
+
const host = PRODUCT_HOSTS[product];
|
|
38
|
+
const path = slug ? `/${slug.replace(/^\//, '')}` : '/';
|
|
39
|
+
const url = `${host}${path}`;
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
headers: {
|
|
42
|
+
'User-Agent': 'ferrlabs-mcp/4.0.0',
|
|
43
|
+
Accept: 'text/html,text/markdown,text/plain',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new Error(`fetch_docs ${url}: HTTP ${res.status}`);
|
|
48
|
+
}
|
|
49
|
+
const raw = await res.text();
|
|
50
|
+
const text = stripHtml(raw);
|
|
51
|
+
const truncated = text.length > MAX_BYTES;
|
|
52
|
+
const out = truncated ? `${text.slice(0, MAX_BYTES)}\n\n[truncated]` : text;
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: `# ${url}\n\n${out}`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
10
|
+
export function registerIssuesTools(server) {
|
|
11
|
+
server.tool('list_issues', 'List FerrTrack issues for a project (FerrTrack — issue tracker)', {
|
|
12
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
13
|
+
project_slug: z.string().min(1).describe('Project slug'),
|
|
14
|
+
page: z.number().int().min(1).optional().describe('Page number (default 1)'),
|
|
15
|
+
per_page: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(100)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Items per page (default 25, max 100)'),
|
|
22
|
+
}, async ({ org_slug, project_slug, page, per_page }) => {
|
|
23
|
+
const token = requireToken();
|
|
24
|
+
const params = new URLSearchParams();
|
|
25
|
+
if (page !== undefined)
|
|
26
|
+
params.set('page', String(page));
|
|
27
|
+
if (per_page !== undefined)
|
|
28
|
+
params.set('per_page', String(per_page));
|
|
29
|
+
const qs = params.toString();
|
|
30
|
+
const issues = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/projects/${encodeURIComponent(project_slug)}/issues${qs ? `?${qs}` : ''}`, { token });
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: JSON.stringify(issues, null, 2) }],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
10
|
+
export function registerOrgsTools(server) {
|
|
11
|
+
server.tool('list_orgs', 'List FerrLabs organizations the authenticated user belongs to', {}, async () => {
|
|
12
|
+
const token = requireToken();
|
|
13
|
+
const orgs = await apiRequest('/v1/orgs', { token });
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: 'text', text: JSON.stringify(orgs, null, 2) }],
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
server.tool('list_projects', 'List projects inside a FerrLabs organization', {
|
|
19
|
+
org_slug: z.string().min(1).describe('Organization slug (from list_orgs)'),
|
|
20
|
+
}, async ({ org_slug }) => {
|
|
21
|
+
const token = requireToken();
|
|
22
|
+
const projects = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/projects`, { token });
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }],
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/tools/stats.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAgBpE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,QA6BnD"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { apiRequest } from './api-client.js';
|
|
2
|
+
export function registerStatsTools(server) {
|
|
3
|
+
server.tool('get_stats', 'Get public FerrLabs usage statistics (releases cut, repos using FerrFlow, commits analyzed)', {}, async () => {
|
|
4
|
+
const stats = await apiRequest('/stats');
|
|
5
|
+
return {
|
|
6
|
+
content: [
|
|
7
|
+
{
|
|
8
|
+
type: 'text',
|
|
9
|
+
text: JSON.stringify(stats, null, 2),
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
server.tool('health_check', 'Check FerrLabs API health status', {}, async () => {
|
|
15
|
+
const health = await apiRequest('/health');
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: 'text',
|
|
20
|
+
text: JSON.stringify(health, null, 2),
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
10
|
+
export function registerSubscriptionsTools(server) {
|
|
11
|
+
server.tool('list_subscriptions', 'List FerrLabs product subscriptions for an organization (FerrVault, FerrTrack, FerrGrowth, FerrFleet)', {
|
|
12
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
13
|
+
}, async ({ org_slug }) => {
|
|
14
|
+
const token = requireToken();
|
|
15
|
+
const subs = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/subscriptions`, { token });
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }],
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
10
|
+
export function registerTokenTools(server) {
|
|
11
|
+
server.tool('get_me', 'Get the current authenticated FerrLabs user profile', {}, async () => {
|
|
12
|
+
const token = requireToken();
|
|
13
|
+
const user = await apiRequest('/v1/auth/me', { token });
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: JSON.stringify(user, null, 2),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
server.tool('list_tokens', 'List all API tokens for the authenticated user', {}, async () => {
|
|
24
|
+
const token = requireToken();
|
|
25
|
+
const tokens = await apiRequest('/v1/auth/tokens', { token });
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: 'text',
|
|
30
|
+
text: JSON.stringify(tokens, null, 2),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
server.tool('create_token', 'Create a new FerrLabs API token (requires an interactive session, not a token)', {
|
|
36
|
+
name: z.string().min(1).max(100).describe('Token name'),
|
|
37
|
+
scopes: z.array(z.string()).describe('Token scopes (e.g. ["*"] for all)'),
|
|
38
|
+
expires_at: z.string().optional().describe('Expiration date (ISO 8601)'),
|
|
39
|
+
}, async ({ name, scopes, expires_at }) => {
|
|
40
|
+
const token = requireToken();
|
|
41
|
+
const result = await apiRequest('/v1/auth/tokens', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body: { name, scopes, expires_at },
|
|
44
|
+
token,
|
|
45
|
+
});
|
|
46
|
+
const { plaintext, ...meta } = result;
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: `Token created: ${plaintext}\n\nThis is the only time the full token will be shown. Store it securely.\n\n${JSON.stringify(meta, null, 2)}`,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
server.tool('revoke_token', 'Revoke a FerrLabs API token by ID', {
|
|
57
|
+
token_id: z.string().uuid().describe('Token ID to revoke'),
|
|
58
|
+
}, async ({ token_id }) => {
|
|
59
|
+
const token = requireToken();
|
|
60
|
+
await apiRequest(`/v1/auth/tokens/${token_id}`, {
|
|
61
|
+
method: 'DELETE',
|
|
62
|
+
token,
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: `Token ${token_id} revoked successfully.`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
10
|
+
export function registerVaultsTools(server) {
|
|
11
|
+
server.tool('list_vaults', 'List FerrVault vaults inside a project (FerrVault — secrets management)', {
|
|
12
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
13
|
+
project_slug: z.string().min(1).describe('Project slug'),
|
|
14
|
+
}, async ({ org_slug, project_slug }) => {
|
|
15
|
+
const token = requireToken();
|
|
16
|
+
const vaults = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/projects/${encodeURIComponent(project_slug)}/vaults`, { token });
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: 'text', text: JSON.stringify(vaults, null, 2) }],
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"ferrlabs-mcp": "./dist/index.js"
|
|
4
|
+
},
|
|
5
|
+
"dependencies": {
|
|
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
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"ferrlabs",
|
|
33
|
+
"ferrflow",
|
|
34
|
+
"ferrvault",
|
|
35
|
+
"ferrtrack",
|
|
36
|
+
"ferrgrowth",
|
|
37
|
+
"ferrfleet",
|
|
38
|
+
"ferrlens",
|
|
39
|
+
"model-context-protocol",
|
|
40
|
+
"ai"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"name": "@ferrlabs/mcp",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://github.com/FerrLabs/MCP.git"
|
|
51
|
+
},
|
|
52
|
+
"type": "module",
|
|
53
|
+
"version": "4.0.0",
|
|
54
|
+
"scripts": {
|
|
55
|
+
"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
|
+
"start": "node dist/index.js",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:coverage": "vitest run --coverage",
|
|
64
|
+
"typecheck": "tsc --noEmit"
|
|
65
|
+
}
|
|
66
|
+
}
|