Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions mcp/packages/ferrgrowth-mcp/src/__tests__/path-segment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { pathSegment } from '../api-base.js';

describe('pathSegment', () => {
it.each(['.', '..'])('rejects the dot segment %s', (value) => {
expect(() => pathSegment(value)).toThrow(/Invalid path segment/);
});

it('encodes reserved characters', () => {
expect(pathSegment('a/b?c')).toBe('a%2Fb%3Fc');
});

it('keeps dots inside a longer segment', () => {
expect(pathSegment('v1.2..3')).toBe('v1.2..3');
});
});
7 changes: 7 additions & 0 deletions mcp/packages/ferrgrowth-mcp/src/api-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,10 @@ export async function growthRequest<T>(path: string, options: GrowthRequestOptio
throw error;
}
}

export function pathSegment(value: string): string {
if (value === '.' || value === '..') {
throw new Error(`Invalid path segment: "${value}"`);
}
return encodeURIComponent(value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { McpServer } from '@ferrlabs/mcp-core';

process.env.FERRLABS_API_TOKEN = 'test-token';

type Handler = (params: Record<string, unknown>) => Promise<unknown>;

const handlers = new Map<string, Handler>();
const mockServer = {
tool: vi.fn((name: string, _desc: string, _schema: unknown, handler: Handler) => {
handlers.set(name, handler);
}),
} as unknown as McpServer;

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

describe('dot segments in path arguments', () => {
beforeEach(async () => {
vi.clearAllMocks();
handlers.clear();
const { registerReleaseTools } = await import('../releases.js');
const { registerPageTools } = await import('../pages.js');
registerReleaseTools(mockServer);
registerPageTools(mockServer);
});

it('restore_release refuses ".." as release_id', async () => {
await expect(
handlers.get('restore_release')!({ site_id: 'acme', release_id: '..' }),
).rejects.toThrow(/Invalid path segment/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('get_page refuses ".." as site_id', async () => {
await expect(handlers.get('get_page')!({ site_id: '..', page_slug: 'home' })).rejects.toThrow(
/Invalid path segment/,
);
expect(mockFetch).not.toHaveBeenCalled();
});
});
10 changes: 5 additions & 5 deletions mcp/packages/ferrgrowth-mcp/src/tools/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

interface PeriodTotals {
visits: number;
Expand Down Expand Up @@ -58,7 +58,7 @@ export function registerAnalyticsTools(server: McpServer) {
const token = await getToken();
const qs = range_days !== undefined ? `?range_days=${range_days}` : '';
const summary = await growthRequest<AnalyticsSummary>(
`/sites/${encodeURIComponent(site_id)}/analytics${qs}`,
`/sites/${pathSegment(site_id)}/analytics${qs}`,
{ token, orgId: org_id },
);
return {
Expand All @@ -77,7 +77,7 @@ export function registerAnalyticsTools(server: McpServer) {
async ({ org_id, site_id }) => {
const token = await getToken();
const snapshot = await growthRequest<RealtimeSnapshot>(
`/sites/${encodeURIComponent(site_id)}/analytics/realtime`,
`/sites/${pathSegment(site_id)}/analytics/realtime`,
{ token, orgId: org_id },
);
return {
Expand All @@ -96,7 +96,7 @@ export function registerAnalyticsTools(server: McpServer) {
async ({ org_id, site_id }) => {
const token = await getToken();
const status = await growthRequest<TrackingStatus>(
`/sites/${encodeURIComponent(site_id)}/tracking/status`,
`/sites/${pathSegment(site_id)}/tracking/status`,
{ token, orgId: org_id },
);
return {
Expand Down Expand Up @@ -124,7 +124,7 @@ export function registerAnalyticsTools(server: McpServer) {
const token = await getToken();
const qs = days !== undefined ? `?days=${days}` : '';
const heatmap = await growthRequest<Heatmap>(
`/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_slug)}/heatmap${qs}`,
`/sites/${pathSegment(site_id)}/pages/${pathSegment(page_slug)}/heatmap${qs}`,
{ token, orgId: org_id },
);
return {
Expand Down
6 changes: 3 additions & 3 deletions mcp/packages/ferrgrowth-mcp/src/tools/blog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

const BLOG_STATUSES = ['draft', 'scheduled', 'published', 'archived'] as const;

Expand All @@ -24,8 +24,8 @@ const siteSlug = z.string().min(1).describe('Site slug');
const postId = z.string().min(1).describe('Blog post id');

function postPath(site: string, id?: string): string {
const base = `/sites/${encodeURIComponent(site)}/blog/posts`;
return id === undefined ? base : `${base}/${encodeURIComponent(id)}`;
const base = `/sites/${pathSegment(site)}/blog/posts`;
return id === undefined ? base : `${base}/${pathSegment(id)}`;
}

export function registerBlogTools(server: McpServer) {
Expand Down
10 changes: 5 additions & 5 deletions mcp/packages/ferrgrowth-mcp/src/tools/contacts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

interface Contact {
id: string;
Expand Down Expand Up @@ -67,10 +67,10 @@ export function registerContactTools(server: McpServer) {
},
async ({ org_id, contact_id }) => {
const token = await getToken();
const contact = await growthRequest<ContactDetail>(
`/contacts/${encodeURIComponent(contact_id)}`,
{ token, orgId: org_id },
);
const contact = await growthRequest<ContactDetail>(`/contacts/${pathSegment(contact_id)}`, {
token,
orgId: org_id,
});
return {
content: [{ type: 'text' as const, text: toToolText(contact) }],
};
Expand Down
6 changes: 3 additions & 3 deletions mcp/packages/ferrgrowth-mcp/src/tools/email-templates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

const TRIGGER_KINDS = [
'form_submit',
Expand Down Expand Up @@ -35,8 +35,8 @@ const triggerKind = z.enum(TRIGGER_KINDS).describe('Event that sends the email')
const brandInherit = z.boolean().describe("Inherit the site's brand styling (default true)");

function templatePath(site: string, id?: string): string {
const base = `/sites/${encodeURIComponent(site)}/email-templates`;
return id === undefined ? base : `${base}/${encodeURIComponent(id)}`;
const base = `/sites/${pathSegment(site)}/email-templates`;
return id === undefined ? base : `${base}/${pathSegment(id)}`;
}

export function registerEmailTemplateTools(server: McpServer) {
Expand Down
6 changes: 3 additions & 3 deletions mcp/packages/ferrgrowth-mcp/src/tools/external-sites.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

interface DiscoverExternalResult {
discovered: number;
Expand All @@ -18,7 +18,7 @@ export function registerExternalSiteTools(server: McpServer) {
async ({ org_id, site_id }) => {
const token = await getToken();
const site = await growthRequest<unknown>(
`/sites/${encodeURIComponent(site_id)}/external-url/verify`,
`/sites/${pathSegment(site_id)}/external-url/verify`,
{ token, orgId: org_id, method: 'POST' },
);
return {
Expand All @@ -41,7 +41,7 @@ export function registerExternalSiteTools(server: McpServer) {
async ({ org_id, site_id, deep }) => {
const token = await getToken();
const result = await growthRequest<DiscoverExternalResult>(
`/sites/${encodeURIComponent(site_id)}/discover-external`,
`/sites/${pathSegment(site_id)}/discover-external`,
{ token, orgId: org_id, method: 'POST', body: { deep: deep ?? false } },
);
return {
Expand Down
21 changes: 11 additions & 10 deletions mcp/packages/ferrgrowth-mcp/src/tools/forms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

interface Form {
id: string;
Expand Down Expand Up @@ -50,7 +50,7 @@ export function registerFormTools(server: McpServer) {
},
async ({ org_id, site_id, form_id }) => {
const token = await getToken();
const forms = await growthRequest<Form[]>(`/sites/${encodeURIComponent(site_id)}/forms`, {
const forms = await growthRequest<Form[]>(`/sites/${pathSegment(site_id)}/forms`, {
token,
orgId: org_id,
});
Expand All @@ -76,7 +76,7 @@ export function registerFormTools(server: McpServer) {
},
async ({ org_id, site_id }) => {
const token = await getToken();
const forms = await growthRequest<Form[]>(`/sites/${encodeURIComponent(site_id)}/forms`, {
const forms = await growthRequest<Form[]>(`/sites/${pathSegment(site_id)}/forms`, {
token,
orgId: org_id,
});
Expand Down Expand Up @@ -104,7 +104,7 @@ export function registerFormTools(server: McpServer) {
async ({ org_id, site_id, form_id, limit }) => {
const token = await getToken();
const latest = await growthRequest<FormSubmission[]>(
`/sites/${encodeURIComponent(site_id)}/forms/${encodeURIComponent(form_id)}/submissions`,
`/sites/${pathSegment(site_id)}/forms/${pathSegment(form_id)}/submissions`,
{ token, orgId: org_id },
);
const subs = latest.slice(0, limit ?? SUBMISSIONS_RETURNED_BY_API);
Expand Down Expand Up @@ -147,7 +147,7 @@ export function registerFormTools(server: McpServer) {
},
async ({ org_id, site_id, ...formBody }) => {
const token = await getToken();
const form = await growthRequest<Form>(`/sites/${encodeURIComponent(site_id)}/forms`, {
const form = await growthRequest<Form>(`/sites/${pathSegment(site_id)}/forms`, {
token,
orgId: org_id,
method: 'POST',
Expand Down Expand Up @@ -188,7 +188,7 @@ export function registerFormTools(server: McpServer) {
if (v !== undefined) body[k] = v;
}
const form = await growthRequest<Form>(
`/sites/${encodeURIComponent(site_id)}/forms/${encodeURIComponent(form_id)}`,
`/sites/${pathSegment(site_id)}/forms/${pathSegment(form_id)}`,
{ token, orgId: org_id, method: 'PATCH', body },
);
return {
Expand All @@ -207,10 +207,11 @@ export function registerFormTools(server: McpServer) {
},
async ({ org_id, site_id, form_id }) => {
const token = await getToken();
await growthRequest<void>(
`/sites/${encodeURIComponent(site_id)}/forms/${encodeURIComponent(form_id)}`,
{ token, orgId: org_id, method: 'DELETE' },
);
await growthRequest<void>(`/sites/${pathSegment(site_id)}/forms/${pathSegment(form_id)}`, {
token,
orgId: org_id,
method: 'DELETE',
});
return {
content: [{ type: 'text' as const, text: `Form ${form_id} deleted.` }],
};
Expand Down
18 changes: 9 additions & 9 deletions mcp/packages/ferrgrowth-mcp/src/tools/funnels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

const STEP_KINDS = ['pageview', 'event', 'form'] as const;

Expand Down Expand Up @@ -62,10 +62,10 @@ export function registerFunnelTools(server: McpServer) {
},
async ({ org_id, site_id }) => {
const token = await getToken();
const funnels = await growthRequest<Funnel[]>(
`/sites/${encodeURIComponent(site_id)}/funnels`,
{ token, orgId: org_id },
);
const funnels = await growthRequest<Funnel[]>(`/sites/${pathSegment(site_id)}/funnels`, {
token,
orgId: org_id,
});
return {
content: [{ type: 'text' as const, text: toToolText(funnels) }],
};
Expand All @@ -83,7 +83,7 @@ export function registerFunnelTools(server: McpServer) {
},
async ({ org_id, site_id, name, steps }) => {
const token = await getToken();
const funnel = await growthRequest<Funnel>(`/sites/${encodeURIComponent(site_id)}/funnels`, {
const funnel = await growthRequest<Funnel>(`/sites/${pathSegment(site_id)}/funnels`, {
token,
orgId: org_id,
method: 'POST',
Expand All @@ -108,7 +108,7 @@ export function registerFunnelTools(server: McpServer) {
async ({ org_id, site_id, funnel_id, name, steps }) => {
const token = await getToken();
const funnel = await growthRequest<Funnel>(
`/sites/${encodeURIComponent(site_id)}/funnels/${encodeURIComponent(funnel_id)}`,
`/sites/${pathSegment(site_id)}/funnels/${pathSegment(funnel_id)}`,
{
token,
orgId: org_id,
Expand Down Expand Up @@ -136,7 +136,7 @@ export function registerFunnelTools(server: McpServer) {
async ({ org_id, site_id, funnel_id }) => {
const token = await getToken();
await growthRequest<void>(
`/sites/${encodeURIComponent(site_id)}/funnels/${encodeURIComponent(funnel_id)}`,
`/sites/${pathSegment(site_id)}/funnels/${pathSegment(funnel_id)}`,
{ token, orgId: org_id, method: 'DELETE' },
);
return {
Expand Down Expand Up @@ -164,7 +164,7 @@ export function registerFunnelTools(server: McpServer) {
const token = await getToken();
const qs = days !== undefined ? `?days=${days}` : '';
const analytics = await growthRequest<FunnelAnalytics>(
`/sites/${encodeURIComponent(site_id)}/funnels/${encodeURIComponent(funnel_id)}/analytics${qs}`,
`/sites/${pathSegment(site_id)}/funnels/${pathSegment(funnel_id)}/analytics${qs}`,
{ token, orgId: org_id },
);
return {
Expand Down
17 changes: 9 additions & 8 deletions mcp/packages/ferrgrowth-mcp/src/tools/goals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

const GOAL_KINDS = ['pageview', 'event', 'form'] as const;

Expand Down Expand Up @@ -35,7 +35,7 @@ export function registerGoalTools(server: McpServer) {
},
async ({ org_id, site_id }) => {
const token = await getToken();
const goals = await growthRequest<Goal[]>(`/sites/${encodeURIComponent(site_id)}/goals`, {
const goals = await growthRequest<Goal[]>(`/sites/${pathSegment(site_id)}/goals`, {
token,
orgId: org_id,
});
Expand All @@ -61,7 +61,7 @@ export function registerGoalTools(server: McpServer) {
},
async ({ org_id, site_id, name, kind, target }) => {
const token = await getToken();
const goal = await growthRequest<Goal>(`/sites/${encodeURIComponent(site_id)}/goals`, {
const goal = await growthRequest<Goal>(`/sites/${pathSegment(site_id)}/goals`, {
token,
orgId: org_id,
method: 'POST',
Expand All @@ -83,10 +83,11 @@ export function registerGoalTools(server: McpServer) {
},
async ({ org_id, site_id, goal_id }) => {
const token = await getToken();
await growthRequest<void>(
`/sites/${encodeURIComponent(site_id)}/goals/${encodeURIComponent(goal_id)}`,
{ token, orgId: org_id, method: 'DELETE' },
);
await growthRequest<void>(`/sites/${pathSegment(site_id)}/goals/${pathSegment(goal_id)}`, {
token,
orgId: org_id,
method: 'DELETE',
});
return {
content: [{ type: 'text' as const, text: `Goal ${goal_id} deleted.` }],
};
Expand All @@ -111,7 +112,7 @@ export function registerGoalTools(server: McpServer) {
const token = await getToken();
const qs = days !== undefined ? `?days=${days}` : '';
const report = await growthRequest<GoalsReport>(
`/sites/${encodeURIComponent(site_id)}/goals/stats${qs}`,
`/sites/${pathSegment(site_id)}/goals/stats${qs}`,
{ token, orgId: org_id },
);
return {
Expand Down
4 changes: 2 additions & 2 deletions mcp/packages/ferrgrowth-mcp/src/tools/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { getToken, type McpServer, toToolText } from '@ferrlabs/mcp-core';
import { growthRequest, orgIdSchema } from '../api-base.js';
import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js';

const INTEGRATION_KINDS = [
'stripe',
Expand Down Expand Up @@ -45,7 +45,7 @@ export function registerIntegrationTools(server: McpServer) {
},
async ({ org_id, kind }) => {
const token = await getToken();
await growthRequest<void>(`/integrations/${encodeURIComponent(kind)}/disconnect`, {
await growthRequest<void>(`/integrations/${pathSegment(kind)}/disconnect`, {
token,
orgId: org_id,
method: 'POST',
Expand Down
Loading
Loading