@ferrlabs/mcp 6.4.1 → 6.5.4
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 +6 -2
- package/dist/tools/__tests__/docs.test.d.ts +2 -0
- package/dist/tools/__tests__/docs.test.d.ts.map +1 -0
- package/dist/tools/__tests__/docs.test.js +41 -0
- package/dist/tools/docs.d.ts +2 -0
- package/dist/tools/docs.d.ts.map +1 -1
- package/dist/tools/docs.js +37 -17
- package/dist/tools/me.d.ts +3 -0
- package/dist/tools/me.d.ts.map +1 -0
- package/dist/tools/me.js +47 -0
- package/dist/tools/org-admin.d.ts +3 -0
- package/dist/tools/org-admin.d.ts.map +1 -0
- package/dist/tools/org-admin.js +184 -0
- package/dist/tools/subscriptions.d.ts.map +1 -1
- package/dist/tools/subscriptions.js +40 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { runMcp } from '@ferrlabs/mcp-core';
|
|
2
|
+
import { runMcp, readPackageVersion } from '@ferrlabs/mcp-core';
|
|
3
3
|
import { registerStatsTools } from './tools/stats.js';
|
|
4
4
|
import { registerTokenTools } from './tools/tokens.js';
|
|
5
5
|
import { registerOrgsTools } from './tools/orgs.js';
|
|
6
|
+
import { registerOrgAdminTools } from './tools/org-admin.js';
|
|
7
|
+
import { registerMeTools } from './tools/me.js';
|
|
6
8
|
import { registerVaultsTools } from './tools/vaults.js';
|
|
7
9
|
import { registerIssuesTools } from './tools/issues.js';
|
|
8
10
|
import { registerSubscriptionsTools } from './tools/subscriptions.js';
|
|
9
11
|
import { registerDocsTools } from './tools/docs.js';
|
|
10
12
|
runMcp({
|
|
11
13
|
name: 'ferrlabs',
|
|
12
|
-
version:
|
|
14
|
+
version: readPackageVersion(import.meta.url),
|
|
13
15
|
register: (server) => {
|
|
14
16
|
registerStatsTools(server);
|
|
15
17
|
registerTokenTools(server);
|
|
16
18
|
registerOrgsTools(server);
|
|
19
|
+
registerOrgAdminTools(server);
|
|
20
|
+
registerMeTools(server);
|
|
17
21
|
registerVaultsTools(server);
|
|
18
22
|
registerIssuesTools(server);
|
|
19
23
|
registerSubscriptionsTools(server);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docs.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/docs.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { buildDocUrl, fetchDoc } from '../docs.js';
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.unstubAllGlobals();
|
|
5
|
+
});
|
|
6
|
+
describe('buildDocUrl', () => {
|
|
7
|
+
it('maps a product and slug to the allowlisted host', () => {
|
|
8
|
+
expect(buildDocUrl('ferrflow', 'docs/getting-started')).toBe('https://ferrflow.com/docs/getting-started');
|
|
9
|
+
expect(buildDocUrl('ferrlabs')).toBe('https://ferrlabs.com/');
|
|
10
|
+
});
|
|
11
|
+
it('rejects slugs containing a scheme, traversal, or a leading //', () => {
|
|
12
|
+
expect(() => buildDocUrl('ferrflow', 'https://evil.test')).toThrow(/invalid slug/);
|
|
13
|
+
expect(() => buildDocUrl('ferrflow', '../../etc/passwd')).toThrow(/invalid slug/);
|
|
14
|
+
expect(() => buildDocUrl('ferrflow', '//evil.test/x')).toThrow(/invalid slug/);
|
|
15
|
+
});
|
|
16
|
+
it('rejects an unknown product', () => {
|
|
17
|
+
expect(() => buildDocUrl('notaproduct')).toThrow(/unknown product/);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('fetchDoc', () => {
|
|
21
|
+
function makeResponse(status, body = '') {
|
|
22
|
+
return {
|
|
23
|
+
ok: status >= 200 && status < 300,
|
|
24
|
+
status,
|
|
25
|
+
text: () => Promise.resolve(body),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
it('does not follow a redirect off the allowlisted host', async () => {
|
|
29
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(302));
|
|
30
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
31
|
+
await expect(fetchDoc('ferrgrowth', 'r')).rejects.toThrow(/refused to follow redirect/);
|
|
32
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(fetchMock.mock.calls[0][1]).toMatchObject({ redirect: 'manual' });
|
|
34
|
+
});
|
|
35
|
+
it('returns stripped text on a 200', async () => {
|
|
36
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(200, '<main><p>hello world</p></main>')));
|
|
37
|
+
const out = await fetchDoc('ferrflow', 'pricing');
|
|
38
|
+
expect(out).toContain('# https://ferrflow.com/pricing');
|
|
39
|
+
expect(out).toContain('hello world');
|
|
40
|
+
});
|
|
41
|
+
});
|
package/dist/tools/docs.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function buildDocUrl(product: string, slug?: string): string;
|
|
3
|
+
export declare function fetchDoc(product: string, slug?: string): Promise<string>;
|
|
2
4
|
export declare function registerDocsTools(server: McpServer): void;
|
|
3
5
|
//# sourceMappingURL=docs.d.ts.map
|
package/dist/tools/docs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/tools/docs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/tools/docs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAkCpE,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAclE;AAED,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0B9E;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,QAyBlD"}
|
package/dist/tools/docs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { fetchWithTimeout } from '@ferrlabs/mcp-core';
|
|
2
3
|
const PRODUCT_HOSTS = {
|
|
3
4
|
ferrlabs: 'https://ferrlabs.com',
|
|
4
5
|
ferrflow: 'https://ferrflow.com',
|
|
@@ -26,6 +27,40 @@ function stripHtml(html) {
|
|
|
26
27
|
.replace(/\n{3,}/g, '\n\n')
|
|
27
28
|
.trim();
|
|
28
29
|
}
|
|
30
|
+
export function buildDocUrl(product, slug) {
|
|
31
|
+
const host = PRODUCT_HOSTS[product];
|
|
32
|
+
if (!host) {
|
|
33
|
+
throw new Error(`fetch_docs: unknown product '${product}'`);
|
|
34
|
+
}
|
|
35
|
+
if (slug !== undefined) {
|
|
36
|
+
if (slug.includes('://') || slug.includes('..') || slug.startsWith('//')) {
|
|
37
|
+
throw new Error(`fetch_docs: invalid slug '${slug}' — must be a relative path without '://', '..', or a leading '//'`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const path = slug ? `/${slug.replace(/^\//, '')}` : '/';
|
|
41
|
+
return `${host}${path}`;
|
|
42
|
+
}
|
|
43
|
+
export async function fetchDoc(product, slug) {
|
|
44
|
+
const url = buildDocUrl(product, slug);
|
|
45
|
+
const res = await fetchWithTimeout(url, {
|
|
46
|
+
redirect: 'manual',
|
|
47
|
+
headers: {
|
|
48
|
+
'User-Agent': 'ferrlabs-mcp',
|
|
49
|
+
Accept: 'text/html,text/markdown,text/plain',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
if (res.status >= 300 && res.status < 400) {
|
|
53
|
+
throw new Error(`fetch_docs ${url}: refused to follow redirect (HTTP ${res.status}) off the allowlisted host`);
|
|
54
|
+
}
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
throw new Error(`fetch_docs ${url}: HTTP ${res.status}`);
|
|
57
|
+
}
|
|
58
|
+
const raw = await res.text();
|
|
59
|
+
const text = stripHtml(raw);
|
|
60
|
+
const truncated = text.length > MAX_BYTES;
|
|
61
|
+
const out = truncated ? `${text.slice(0, MAX_BYTES)}\n\n[truncated]` : text;
|
|
62
|
+
return `# ${url}\n\n${out}`;
|
|
63
|
+
}
|
|
29
64
|
export function registerDocsTools(server) {
|
|
30
65
|
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
66
|
product: productEnum.describe('FerrLabs product whose docs to fetch'),
|
|
@@ -34,27 +69,12 @@ export function registerDocsTools(server) {
|
|
|
34
69
|
.optional()
|
|
35
70
|
.describe("Path on the product site, e.g. 'docs/getting-started' or 'pricing'. Omit for the homepage."),
|
|
36
71
|
}, async ({ product, slug }) => {
|
|
37
|
-
const
|
|
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;
|
|
72
|
+
const text = await fetchDoc(product, slug);
|
|
53
73
|
return {
|
|
54
74
|
content: [
|
|
55
75
|
{
|
|
56
76
|
type: 'text',
|
|
57
|
-
text
|
|
77
|
+
text,
|
|
58
78
|
},
|
|
59
79
|
],
|
|
60
80
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"me.d.ts","sourceRoot":"","sources":["../../src/tools/me.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA4BpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,QAkEhD"}
|
package/dist/tools/me.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiRequest, getToken } from '@ferrlabs/mcp-core';
|
|
3
|
+
export function registerMeTools(server) {
|
|
4
|
+
server.tool('update_me', "Patch the authenticated user's own profile — display name, timezone, locale, avatar URL. Email + password rotate via dedicated flows in the UI, not here.", {
|
|
5
|
+
display_name: z.string().min(1).max(100).optional(),
|
|
6
|
+
timezone: z.string().min(1).max(64).optional().describe("IANA tz, e.g. 'Europe/Paris'."),
|
|
7
|
+
locale: z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("BCP 47, e.g. 'en' or 'fr-FR'."),
|
|
12
|
+
avatar_url: z.string().url().nullable().optional(),
|
|
13
|
+
}, async (patch) => {
|
|
14
|
+
const token = await getToken();
|
|
15
|
+
const body = {};
|
|
16
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
17
|
+
if (v !== undefined)
|
|
18
|
+
body[k] = v;
|
|
19
|
+
}
|
|
20
|
+
const me = await apiRequest('/v1/auth/me', {
|
|
21
|
+
token,
|
|
22
|
+
method: 'PATCH',
|
|
23
|
+
body,
|
|
24
|
+
});
|
|
25
|
+
return { content: [{ type: 'text', text: JSON.stringify(me, null, 2) }] };
|
|
26
|
+
});
|
|
27
|
+
server.tool('list_my_sessions', 'List your active sessions across devices/browsers. The session you used to obtain the current token is flagged `current: true`.', {}, async () => {
|
|
28
|
+
const token = await getToken();
|
|
29
|
+
const sessions = await apiRequest('/v1/me/sessions', { token });
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify(sessions, null, 2) }] };
|
|
31
|
+
});
|
|
32
|
+
server.tool('revoke_my_session', 'Revoke one of your active sessions by id (sign out that device). Refusing to revoke the current session is left up to the API.', {
|
|
33
|
+
session_id: z.string().min(1).describe('Session id (from list_my_sessions)'),
|
|
34
|
+
}, async ({ session_id }) => {
|
|
35
|
+
const token = await getToken();
|
|
36
|
+
await apiRequest(`/v1/me/sessions/${encodeURIComponent(session_id)}`, {
|
|
37
|
+
token,
|
|
38
|
+
method: 'DELETE',
|
|
39
|
+
});
|
|
40
|
+
return { content: [{ type: 'text', text: `Session ${session_id} revoked.` }] };
|
|
41
|
+
});
|
|
42
|
+
server.tool('export_my_account', "Trigger an export of the authenticated user's data (GDPR Article 15). Returns the export bundle, which can be large; consider redirecting the LLM context to a summary.", {}, async () => {
|
|
43
|
+
const token = await getToken();
|
|
44
|
+
const dump = await apiRequest('/v1/auth/me/export', { token });
|
|
45
|
+
return { content: [{ type: 'text', text: JSON.stringify(dump, null, 2) }] };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-admin.d.ts","sourceRoot":"","sources":["../../src/tools/org-admin.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+DpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAwRtD"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiRequest, getToken } from '@ferrlabs/mcp-core';
|
|
3
|
+
function orgBase(slug) {
|
|
4
|
+
return `/v1/orgs/${encodeURIComponent(slug)}`;
|
|
5
|
+
}
|
|
6
|
+
export function registerOrgAdminTools(server) {
|
|
7
|
+
server.tool('get_org', 'Get a single FerrLabs organization by slug.', {
|
|
8
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
9
|
+
}, async ({ org_slug }) => {
|
|
10
|
+
const token = await getToken();
|
|
11
|
+
const org = await apiRequest(orgBase(org_slug), { token });
|
|
12
|
+
return { content: [{ type: 'text', text: JSON.stringify(org, null, 2) }] };
|
|
13
|
+
});
|
|
14
|
+
server.tool('update_org', 'Patch an organization — rename it or change its slug. Slug changes propagate everywhere; double-check before doing it on a live org.', {
|
|
15
|
+
org_slug: z.string().min(1).describe('Current organization slug'),
|
|
16
|
+
name: z.string().min(1).max(100).optional(),
|
|
17
|
+
new_slug: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(2)
|
|
20
|
+
.max(40)
|
|
21
|
+
.regex(/^[a-z0-9-]+$/)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('New slug (lowercase alphanumeric + hyphens).'),
|
|
24
|
+
}, async ({ org_slug, name, new_slug }) => {
|
|
25
|
+
const token = await getToken();
|
|
26
|
+
const body = {};
|
|
27
|
+
if (name !== undefined)
|
|
28
|
+
body.name = name;
|
|
29
|
+
if (new_slug !== undefined)
|
|
30
|
+
body.slug = new_slug;
|
|
31
|
+
const org = await apiRequest(orgBase(org_slug), {
|
|
32
|
+
token,
|
|
33
|
+
method: 'PATCH',
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
return { content: [{ type: 'text', text: JSON.stringify(org, null, 2) }] };
|
|
37
|
+
});
|
|
38
|
+
server.tool('get_org_overview', 'High-level dashboard view of an organization — member count, active subscriptions per product, pending invites, recent activity volume.', {
|
|
39
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
40
|
+
}, async ({ org_slug }) => {
|
|
41
|
+
const token = await getToken();
|
|
42
|
+
const overview = await apiRequest(`${orgBase(org_slug)}/overview`, { token });
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] };
|
|
44
|
+
});
|
|
45
|
+
server.tool('get_org_usage', 'Current-period metered usage across all products for an org (FerrGrowth visits/bandwidth, FerrFleet runs, etc.) with limits per tier.', {
|
|
46
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
47
|
+
}, async ({ org_slug }) => {
|
|
48
|
+
const token = await getToken();
|
|
49
|
+
const usage = await apiRequest(`${orgBase(org_slug)}/usage`, { token });
|
|
50
|
+
return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
|
|
51
|
+
});
|
|
52
|
+
server.tool('list_org_audit', 'Recent audit log for an organization — member changes, subscription updates, sensitive admin actions.', {
|
|
53
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
54
|
+
limit: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(200)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Max entries to return (default 50).'),
|
|
61
|
+
}, async ({ org_slug, limit }) => {
|
|
62
|
+
const token = await getToken();
|
|
63
|
+
const qs = limit !== undefined ? `?limit=${limit}` : '';
|
|
64
|
+
const entries = await apiRequest(`${orgBase(org_slug)}/audit${qs}`, { token });
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
66
|
+
});
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
// Members
|
|
69
|
+
// --------------------------------------------------------------------------
|
|
70
|
+
server.tool('list_org_members', 'Members of an organization with their roles. Useful to look up a user_id before assigning issues or granting team membership.', {
|
|
71
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
72
|
+
}, async ({ org_slug }) => {
|
|
73
|
+
const token = await getToken();
|
|
74
|
+
const members = await apiRequest(`${orgBase(org_slug)}/members`, { token });
|
|
75
|
+
return { content: [{ type: 'text', text: JSON.stringify(members, null, 2) }] };
|
|
76
|
+
});
|
|
77
|
+
server.tool('invite_org_member', 'Invite a user (by email) to join an organization with a specific role. The user receives an invitation email; they appear in list_org_members once they accept.', {
|
|
78
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
79
|
+
email: z.string().email().describe('Invitee email address'),
|
|
80
|
+
role: z
|
|
81
|
+
.enum(['admin', 'member', 'viewer'])
|
|
82
|
+
.default('member')
|
|
83
|
+
.describe('Org-level role (default member). Owners can only be promoted from existing admins.'),
|
|
84
|
+
}, async ({ org_slug, email, role }) => {
|
|
85
|
+
const token = await getToken();
|
|
86
|
+
const member = await apiRequest(`${orgBase(org_slug)}/members`, {
|
|
87
|
+
token,
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: { email, role },
|
|
90
|
+
});
|
|
91
|
+
return { content: [{ type: 'text', text: JSON.stringify(member, null, 2) }] };
|
|
92
|
+
});
|
|
93
|
+
server.tool('update_org_member_role', "Change a member's role inside an organization. Promoting to owner requires the caller to be an owner.", {
|
|
94
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
95
|
+
user_id: z.string().min(1).describe('User id of the member to update'),
|
|
96
|
+
role: z.enum(['owner', 'admin', 'member', 'viewer']),
|
|
97
|
+
}, async ({ org_slug, user_id, role }) => {
|
|
98
|
+
const token = await getToken();
|
|
99
|
+
const member = await apiRequest(`${orgBase(org_slug)}/members/${encodeURIComponent(user_id)}`, { token, method: 'PATCH', body: { role } });
|
|
100
|
+
return { content: [{ type: 'text', text: JSON.stringify(member, null, 2) }] };
|
|
101
|
+
});
|
|
102
|
+
server.tool('remove_org_member', 'Remove a user from an organization. Irreversible; the user can be re-invited.', {
|
|
103
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
104
|
+
user_id: z.string().min(1).describe('User id of the member to remove'),
|
|
105
|
+
}, async ({ org_slug, user_id }) => {
|
|
106
|
+
const token = await getToken();
|
|
107
|
+
await apiRequest(`${orgBase(org_slug)}/members/${encodeURIComponent(user_id)}`, {
|
|
108
|
+
token,
|
|
109
|
+
method: 'DELETE',
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: 'text', text: `User ${user_id} removed from ${org_slug}.` }],
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
// --------------------------------------------------------------------------
|
|
116
|
+
// Teams
|
|
117
|
+
// --------------------------------------------------------------------------
|
|
118
|
+
server.tool('list_teams', 'List teams (sub-groups) inside an organization. Teams are used for fine-grained permissions and assignee groups.', {
|
|
119
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
120
|
+
}, async ({ org_slug }) => {
|
|
121
|
+
const token = await getToken();
|
|
122
|
+
const teams = await apiRequest(`${orgBase(org_slug)}/teams`, { token });
|
|
123
|
+
return { content: [{ type: 'text', text: JSON.stringify(teams, null, 2) }] };
|
|
124
|
+
});
|
|
125
|
+
server.tool('create_team', 'Create a new team inside an organization. Teams start empty — add members with add_team_member.', {
|
|
126
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
127
|
+
name: z.string().min(1).max(100),
|
|
128
|
+
description: z.string().max(500).optional(),
|
|
129
|
+
}, async ({ org_slug, name, description }) => {
|
|
130
|
+
const token = await getToken();
|
|
131
|
+
const team = await apiRequest(`${orgBase(org_slug)}/teams`, {
|
|
132
|
+
token,
|
|
133
|
+
method: 'POST',
|
|
134
|
+
body: { name, description: description ?? null },
|
|
135
|
+
});
|
|
136
|
+
return { content: [{ type: 'text', text: JSON.stringify(team, null, 2) }] };
|
|
137
|
+
});
|
|
138
|
+
server.tool('update_team', 'Patch a team — rename or change description.', {
|
|
139
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
140
|
+
team_id: z.string().min(1).describe('Team id'),
|
|
141
|
+
name: z.string().min(1).max(100).optional(),
|
|
142
|
+
description: z.string().max(500).nullable().optional(),
|
|
143
|
+
}, async ({ org_slug, team_id, ...patch }) => {
|
|
144
|
+
const token = await getToken();
|
|
145
|
+
const body = {};
|
|
146
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
147
|
+
if (v !== undefined)
|
|
148
|
+
body[k] = v;
|
|
149
|
+
}
|
|
150
|
+
const team = await apiRequest(`${orgBase(org_slug)}/teams/${encodeURIComponent(team_id)}`, { token, method: 'PATCH', body });
|
|
151
|
+
return { content: [{ type: 'text', text: JSON.stringify(team, null, 2) }] };
|
|
152
|
+
});
|
|
153
|
+
server.tool('delete_team', 'Delete a team. Members of the team are NOT removed from the org; they just lose team-scoped permissions.', {
|
|
154
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
155
|
+
team_id: z.string().min(1).describe('Team id'),
|
|
156
|
+
}, async ({ org_slug, team_id }) => {
|
|
157
|
+
const token = await getToken();
|
|
158
|
+
await apiRequest(`${orgBase(org_slug)}/teams/${encodeURIComponent(team_id)}`, {
|
|
159
|
+
token,
|
|
160
|
+
method: 'DELETE',
|
|
161
|
+
});
|
|
162
|
+
return { content: [{ type: 'text', text: `Team ${team_id} deleted.` }] };
|
|
163
|
+
});
|
|
164
|
+
server.tool('add_team_member', 'Add a user (already an org member) to a team.', {
|
|
165
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
166
|
+
team_id: z.string().min(1).describe('Team id'),
|
|
167
|
+
user_id: z.string().min(1).describe('User id to add'),
|
|
168
|
+
}, async ({ org_slug, team_id, user_id }) => {
|
|
169
|
+
const token = await getToken();
|
|
170
|
+
const result = await apiRequest(`${orgBase(org_slug)}/teams/${encodeURIComponent(team_id)}/members`, { token, method: 'POST', body: { user_id } });
|
|
171
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
172
|
+
});
|
|
173
|
+
server.tool('remove_team_member', 'Remove a user from a team. The user remains an org member.', {
|
|
174
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
175
|
+
team_id: z.string().min(1).describe('Team id'),
|
|
176
|
+
user_id: z.string().min(1).describe('User id to remove'),
|
|
177
|
+
}, async ({ org_slug, team_id, user_id }) => {
|
|
178
|
+
const token = await getToken();
|
|
179
|
+
await apiRequest(`${orgBase(org_slug)}/teams/${encodeURIComponent(team_id)}/members/${encodeURIComponent(user_id)}`, { token, method: 'DELETE' });
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: 'text', text: `User ${user_id} removed from team ${team_id}.` }],
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -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;AAgBpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,SAAS,
|
|
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,QAwF3D"}
|
|
@@ -11,4 +11,44 @@ export function registerSubscriptionsTools(server) {
|
|
|
11
11
|
content: [{ type: 'text', text: JSON.stringify(subs, null, 2) }],
|
|
12
12
|
};
|
|
13
13
|
});
|
|
14
|
+
const PRODUCTS = ['ferrvault', 'ferrtrack', 'ferrgrowth', 'ferrfleet', 'ferrlens'];
|
|
15
|
+
const TIERS = ['free', 'pro', 'team', 'enterprise'];
|
|
16
|
+
server.tool('activate_subscription', 'Activate a product subscription on an organization. Starts the trial clock when applicable. Caller must be an org owner/admin; the API enforces billing-info presence for paid tiers.', {
|
|
17
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
18
|
+
product: z.enum(PRODUCTS),
|
|
19
|
+
tier: z.enum(TIERS).default('free').describe('Tier (default free).'),
|
|
20
|
+
}, async ({ org_slug, product, tier }) => {
|
|
21
|
+
const token = await getToken();
|
|
22
|
+
const sub = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/subscriptions`, { token, method: 'POST', body: { product, tier } });
|
|
23
|
+
return { content: [{ type: 'text', text: JSON.stringify(sub, null, 2) }] };
|
|
24
|
+
});
|
|
25
|
+
server.tool('update_subscription', 'Change the tier of an active product subscription (upgrade/downgrade). Stripe proration is applied per the API config.', {
|
|
26
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
27
|
+
product: z.enum(PRODUCTS),
|
|
28
|
+
tier: z.enum(TIERS),
|
|
29
|
+
}, async ({ org_slug, product, tier }) => {
|
|
30
|
+
const token = await getToken();
|
|
31
|
+
const sub = await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/subscriptions/${encodeURIComponent(product)}`, { token, method: 'PATCH', body: { tier } });
|
|
32
|
+
return { content: [{ type: 'text', text: JSON.stringify(sub, null, 2) }] };
|
|
33
|
+
});
|
|
34
|
+
server.tool('cancel_subscription', 'Cancel a product subscription on an organization. By default the cancellation takes effect at the end of the current billing period; pass immediate=true to cut it off now (with proration).', {
|
|
35
|
+
org_slug: z.string().min(1).describe('Organization slug'),
|
|
36
|
+
product: z.enum(PRODUCTS),
|
|
37
|
+
immediate: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Cut off immediately and refund prorated amount. Default false (cancel at period end).'),
|
|
41
|
+
}, async ({ org_slug, product, immediate }) => {
|
|
42
|
+
const token = await getToken();
|
|
43
|
+
const qs = immediate ? '?immediate=true' : '';
|
|
44
|
+
await apiRequest(`/v1/orgs/${encodeURIComponent(org_slug)}/subscriptions/${encodeURIComponent(product)}${qs}`, { token, method: 'DELETE' });
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: 'text',
|
|
49
|
+
text: `Subscription ${product} on ${org_slug} ${immediate ? 'cancelled immediately' : 'set to cancel at period end'}.`,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
});
|
|
14
54
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferrlabs/mcp",
|
|
3
|
-
"version": "6.4
|
|
3
|
+
"version": "6.5.4",
|
|
4
4
|
"description": "Model Context Protocol server for the FerrLabs platform (orgs, projects, subscriptions, docs, tokens, public stats)",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
25
|
"zod": "^4.3.6",
|
|
26
|
-
"@ferrlabs/mcp-core": "^6.4
|
|
26
|
+
"@ferrlabs/mcp-core": "^6.5.4"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|