@ferrlabs/mcp 6.5.0 → 6.5.5

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 CHANGED
@@ -1,5 +1,5 @@
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';
@@ -11,7 +11,7 @@ import { registerSubscriptionsTools } from './tools/subscriptions.js';
11
11
  import { registerDocsTools } from './tools/docs.js';
12
12
  runMcp({
13
13
  name: 'ferrlabs',
14
- version: '5.2.5',
14
+ version: readPackageVersion(import.meta.url),
15
15
  register: (server) => {
16
16
  registerStatsTools(server);
17
17
  registerTokenTools(server);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=docs.test.d.ts.map
@@ -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
+ });
@@ -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
@@ -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;AAiCpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,QA4ClD"}
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"}
@@ -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 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;
72
+ const text = await fetchDoc(product, slug);
53
73
  return {
54
74
  content: [
55
75
  {
56
76
  type: 'text',
57
- text: `# ${url}\n\n${out}`,
77
+ text,
58
78
  },
59
79
  ],
60
80
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrlabs/mcp",
3
- "version": "6.5.0",
3
+ "version": "6.5.5",
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.5.0"
26
+ "@ferrlabs/mcp-core": "^6.5.5"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"