diff --git a/mcp/packages/ferrgrowth-mcp/src/__tests__/path-segment.test.ts b/mcp/packages/ferrgrowth-mcp/src/__tests__/path-segment.test.ts new file mode 100644 index 0000000..a371cea --- /dev/null +++ b/mcp/packages/ferrgrowth-mcp/src/__tests__/path-segment.test.ts @@ -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'); + }); +}); diff --git a/mcp/packages/ferrgrowth-mcp/src/api-base.ts b/mcp/packages/ferrgrowth-mcp/src/api-base.ts index f07536d..56d5d56 100644 --- a/mcp/packages/ferrgrowth-mcp/src/api-base.ts +++ b/mcp/packages/ferrgrowth-mcp/src/api-base.ts @@ -52,3 +52,10 @@ export async function growthRequest(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); +} diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/__tests__/dot-segments.test.ts b/mcp/packages/ferrgrowth-mcp/src/tools/__tests__/dot-segments.test.ts new file mode 100644 index 0000000..f29ba68 --- /dev/null +++ b/mcp/packages/ferrgrowth-mcp/src/tools/__tests__/dot-segments.test.ts @@ -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) => Promise; + +const handlers = new Map(); +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(); + }); +}); diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/analytics.ts b/mcp/packages/ferrgrowth-mcp/src/tools/analytics.ts index c54ad29..d8270c2 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/analytics.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/analytics.ts @@ -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; @@ -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( - `/sites/${encodeURIComponent(site_id)}/analytics${qs}`, + `/sites/${pathSegment(site_id)}/analytics${qs}`, { token, orgId: org_id }, ); return { @@ -77,7 +77,7 @@ export function registerAnalyticsTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const snapshot = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/analytics/realtime`, + `/sites/${pathSegment(site_id)}/analytics/realtime`, { token, orgId: org_id }, ); return { @@ -96,7 +96,7 @@ export function registerAnalyticsTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const status = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/tracking/status`, + `/sites/${pathSegment(site_id)}/tracking/status`, { token, orgId: org_id }, ); return { @@ -124,7 +124,7 @@ export function registerAnalyticsTools(server: McpServer) { const token = await getToken(); const qs = days !== undefined ? `?days=${days}` : ''; const heatmap = await growthRequest( - `/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 { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/blog.ts b/mcp/packages/ferrgrowth-mcp/src/tools/blog.ts index c4ccd98..2d5def0 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/blog.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/blog.ts @@ -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; @@ -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) { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/contacts.ts b/mcp/packages/ferrgrowth-mcp/src/tools/contacts.ts index c17fe9a..cc20f77 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/contacts.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/contacts.ts @@ -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; @@ -67,10 +67,10 @@ export function registerContactTools(server: McpServer) { }, async ({ org_id, contact_id }) => { const token = await getToken(); - const contact = await growthRequest( - `/contacts/${encodeURIComponent(contact_id)}`, - { token, orgId: org_id }, - ); + const contact = await growthRequest(`/contacts/${pathSegment(contact_id)}`, { + token, + orgId: org_id, + }); return { content: [{ type: 'text' as const, text: toToolText(contact) }], }; diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/email-templates.ts b/mcp/packages/ferrgrowth-mcp/src/tools/email-templates.ts index 0688a4f..5d6f5d8 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/email-templates.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/email-templates.ts @@ -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', @@ -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) { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/external-sites.ts b/mcp/packages/ferrgrowth-mcp/src/tools/external-sites.ts index f428683..e2829ae 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/external-sites.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/external-sites.ts @@ -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; @@ -18,7 +18,7 @@ export function registerExternalSiteTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const site = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/external-url/verify`, + `/sites/${pathSegment(site_id)}/external-url/verify`, { token, orgId: org_id, method: 'POST' }, ); return { @@ -41,7 +41,7 @@ export function registerExternalSiteTools(server: McpServer) { async ({ org_id, site_id, deep }) => { const token = await getToken(); const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/discover-external`, + `/sites/${pathSegment(site_id)}/discover-external`, { token, orgId: org_id, method: 'POST', body: { deep: deep ?? false } }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/forms.ts b/mcp/packages/ferrgrowth-mcp/src/tools/forms.ts index b2bd20f..6a98b6a 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/forms.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/forms.ts @@ -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; @@ -50,7 +50,7 @@ export function registerFormTools(server: McpServer) { }, async ({ org_id, site_id, form_id }) => { const token = await getToken(); - const forms = await growthRequest(`/sites/${encodeURIComponent(site_id)}/forms`, { + const forms = await growthRequest(`/sites/${pathSegment(site_id)}/forms`, { token, orgId: org_id, }); @@ -76,7 +76,7 @@ export function registerFormTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const forms = await growthRequest(`/sites/${encodeURIComponent(site_id)}/forms`, { + const forms = await growthRequest(`/sites/${pathSegment(site_id)}/forms`, { token, orgId: org_id, }); @@ -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( - `/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); @@ -147,7 +147,7 @@ export function registerFormTools(server: McpServer) { }, async ({ org_id, site_id, ...formBody }) => { const token = await getToken(); - const form = await growthRequest
(`/sites/${encodeURIComponent(site_id)}/forms`, { + const form = await growthRequest(`/sites/${pathSegment(site_id)}/forms`, { token, orgId: org_id, method: 'POST', @@ -188,7 +188,7 @@ export function registerFormTools(server: McpServer) { if (v !== undefined) body[k] = v; } const form = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/forms/${encodeURIComponent(form_id)}`, + `/sites/${pathSegment(site_id)}/forms/${pathSegment(form_id)}`, { token, orgId: org_id, method: 'PATCH', body }, ); return { @@ -207,10 +207,11 @@ export function registerFormTools(server: McpServer) { }, async ({ org_id, site_id, form_id }) => { const token = await getToken(); - await growthRequest( - `/sites/${encodeURIComponent(site_id)}/forms/${encodeURIComponent(form_id)}`, - { token, orgId: org_id, method: 'DELETE' }, - ); + await growthRequest(`/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.` }], }; diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/funnels.ts b/mcp/packages/ferrgrowth-mcp/src/tools/funnels.ts index 3b63142..9f91e7f 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/funnels.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/funnels.ts @@ -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; @@ -62,10 +62,10 @@ export function registerFunnelTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const funnels = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/funnels`, - { token, orgId: org_id }, - ); + const funnels = await growthRequest(`/sites/${pathSegment(site_id)}/funnels`, { + token, + orgId: org_id, + }); return { content: [{ type: 'text' as const, text: toToolText(funnels) }], }; @@ -83,7 +83,7 @@ export function registerFunnelTools(server: McpServer) { }, async ({ org_id, site_id, name, steps }) => { const token = await getToken(); - const funnel = await growthRequest(`/sites/${encodeURIComponent(site_id)}/funnels`, { + const funnel = await growthRequest(`/sites/${pathSegment(site_id)}/funnels`, { token, orgId: org_id, method: 'POST', @@ -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( - `/sites/${encodeURIComponent(site_id)}/funnels/${encodeURIComponent(funnel_id)}`, + `/sites/${pathSegment(site_id)}/funnels/${pathSegment(funnel_id)}`, { token, orgId: org_id, @@ -136,7 +136,7 @@ export function registerFunnelTools(server: McpServer) { async ({ org_id, site_id, funnel_id }) => { const token = await getToken(); await growthRequest( - `/sites/${encodeURIComponent(site_id)}/funnels/${encodeURIComponent(funnel_id)}`, + `/sites/${pathSegment(site_id)}/funnels/${pathSegment(funnel_id)}`, { token, orgId: org_id, method: 'DELETE' }, ); return { @@ -164,7 +164,7 @@ export function registerFunnelTools(server: McpServer) { const token = await getToken(); const qs = days !== undefined ? `?days=${days}` : ''; const analytics = await growthRequest( - `/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 { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/goals.ts b/mcp/packages/ferrgrowth-mcp/src/tools/goals.ts index 708e069..a3faa7e 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/goals.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/goals.ts @@ -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; @@ -35,7 +35,7 @@ export function registerGoalTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const goals = await growthRequest(`/sites/${encodeURIComponent(site_id)}/goals`, { + const goals = await growthRequest(`/sites/${pathSegment(site_id)}/goals`, { token, orgId: org_id, }); @@ -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(`/sites/${encodeURIComponent(site_id)}/goals`, { + const goal = await growthRequest(`/sites/${pathSegment(site_id)}/goals`, { token, orgId: org_id, method: 'POST', @@ -83,10 +83,11 @@ export function registerGoalTools(server: McpServer) { }, async ({ org_id, site_id, goal_id }) => { const token = await getToken(); - await growthRequest( - `/sites/${encodeURIComponent(site_id)}/goals/${encodeURIComponent(goal_id)}`, - { token, orgId: org_id, method: 'DELETE' }, - ); + await growthRequest(`/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.` }], }; @@ -111,7 +112,7 @@ export function registerGoalTools(server: McpServer) { const token = await getToken(); const qs = days !== undefined ? `?days=${days}` : ''; const report = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/goals/stats${qs}`, + `/sites/${pathSegment(site_id)}/goals/stats${qs}`, { token, orgId: org_id }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/integrations.ts b/mcp/packages/ferrgrowth-mcp/src/tools/integrations.ts index 29dda76..5915380 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/integrations.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/integrations.ts @@ -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', @@ -45,7 +45,7 @@ export function registerIntegrationTools(server: McpServer) { }, async ({ org_id, kind }) => { const token = await getToken(); - await growthRequest(`/integrations/${encodeURIComponent(kind)}/disconnect`, { + await growthRequest(`/integrations/${pathSegment(kind)}/disconnect`, { token, orgId: org_id, method: 'POST', diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/media.ts b/mcp/packages/ferrgrowth-mcp/src/tools/media.ts index 60088b5..50706a2 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/media.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/media.ts @@ -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 MediaAsset { id: string; @@ -36,7 +36,7 @@ const httpsUrl = z.url({ protocol: /^https$/ }); const siteSlug = z.string().min(1).describe('Site slug'); function mediaPath(site: string): string { - return `/sites/${encodeURIComponent(site)}/media`; + return `/sites/${pathSegment(site)}/media`; } export function registerMediaTools(server: McpServer) { @@ -149,7 +149,7 @@ export function registerMediaTools(server: McpServer) { }, async ({ org_id, site_id, media_id }) => { const token = await getToken(); - await growthRequest(`${mediaPath(site_id)}/${encodeURIComponent(media_id)}`, { + await growthRequest(`${mediaPath(site_id)}/${pathSegment(media_id)}`, { token, orgId: org_id, method: 'DELETE', diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/members.ts b/mcp/packages/ferrgrowth-mcp/src/tools/members.ts index b13aa4a..c85d4ae 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/members.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/members.ts @@ -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 SiteMember { id: string; @@ -19,7 +19,7 @@ interface MemberList { } function memberPath(site_id: string, member_id: string): string { - return `/sites/${encodeURIComponent(site_id)}/members/${encodeURIComponent(member_id)}`; + return `/sites/${pathSegment(site_id)}/members/${pathSegment(member_id)}`; } export function registerMemberTools(server: McpServer) { @@ -39,7 +39,7 @@ export function registerMemberTools(server: McpServer) { if (offset !== undefined) params.set('offset', String(offset)); const qs = params.size > 0 ? `?${params.toString()}` : ''; const members = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/members${qs}`, + `/sites/${pathSegment(site_id)}/members${qs}`, { token, orgId: org_id }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/page-publishing.ts b/mcp/packages/ferrgrowth-mcp/src/tools/page-publishing.ts index 6d61a77..c13b8c0 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/page-publishing.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/page-publishing.ts @@ -1,12 +1,12 @@ 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 siteId = z.string().min(1).describe('Site slug'); const pageSlug = z.string().min(1).describe('Page slug'); function pagePath(site: string, page: string): string { - return `/sites/${encodeURIComponent(site)}/pages/${encodeURIComponent(page)}`; + return `/sites/${pathSegment(site)}/pages/${pathSegment(page)}`; } function textResult(value: unknown) { @@ -114,7 +114,7 @@ export function registerPagePublishingTools(server: McpServer) { async ({ org_id, site_id, link_id }) => { const token = await getToken(); const link = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/preview-links/${encodeURIComponent(link_id)}`, + `/sites/${pathSegment(site_id)}/preview-links/${pathSegment(link_id)}`, { token, orgId: org_id, method: 'DELETE' }, ); return textResult(link); diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/page-revisions.ts b/mcp/packages/ferrgrowth-mcp/src/tools/page-revisions.ts index 02016ce..6918e3f 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/page-revisions.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/page-revisions.ts @@ -1,13 +1,13 @@ 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 siteSlug = z.string().min(1).describe('Site slug'); const pageSlug = z.string().min(1).describe('Page slug'); const revisionId = z.string().min(1).describe('Revision id, from list_page_revisions'); function revisionsPath(site: string, page: string): string { - return `/sites/${encodeURIComponent(site)}/pages/${encodeURIComponent(page)}/revisions`; + return `/sites/${pathSegment(site)}/pages/${pathSegment(page)}/revisions`; } export function registerPageRevisionTools(server: McpServer) { @@ -55,7 +55,7 @@ export function registerPageRevisionTools(server: McpServer) { async ({ org_id, site_id, page_slug, revision_id }) => { const token = await getToken(); const revision = await growthRequest( - `${revisionsPath(site_id, page_slug)}/${encodeURIComponent(revision_id)}`, + `${revisionsPath(site_id, page_slug)}/${pathSegment(revision_id)}`, { token, orgId: org_id }, ); return { @@ -71,7 +71,7 @@ export function registerPageRevisionTools(server: McpServer) { async ({ org_id, site_id, page_slug, revision_id }) => { const token = await getToken(); const page = await growthRequest( - `${revisionsPath(site_id, page_slug)}/${encodeURIComponent(revision_id)}/restore`, + `${revisionsPath(site_id, page_slug)}/${pathSegment(revision_id)}/restore`, { token, orgId: org_id, method: 'POST' }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/page-update-body.ts b/mcp/packages/ferrgrowth-mcp/src/tools/page-update-body.ts index ed66928..b16aafd 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/page-update-body.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/page-update-body.ts @@ -1,9 +1,6 @@ import type { UpdatePageInput } from './page-update-schema.js'; -export function buildUpdatePageBody({ - blocks, - ...rest -}: UpdatePageInput): Record { +export function buildUpdatePageBody({ blocks, ...rest }: UpdatePageInput): Record { const body: Record = {}; if (blocks !== undefined) { body.blocks = blocks.map((block) => ({ ...block, id: block.id ?? crypto.randomUUID() })); diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/pages.ts b/mcp/packages/ferrgrowth-mcp/src/tools/pages.ts index 0df06c5..9620865 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/pages.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/pages.ts @@ -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'; import { buildUpdatePageBody } from './page-update-body.js'; import { updatePageFields } from './page-update-schema.js'; @@ -24,7 +24,7 @@ export function registerPageTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const pages = await growthRequest(`/sites/${encodeURIComponent(site_id)}/pages`, { + const pages = await growthRequest(`/sites/${pathSegment(site_id)}/pages`, { token, orgId: org_id, }); @@ -45,7 +45,7 @@ export function registerPageTools(server: McpServer) { async ({ org_id, site_id, page_slug }) => { const token = await getToken(); const page = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_slug)}`, + `/sites/${pathSegment(site_id)}/pages/${pathSegment(page_slug)}`, { token, orgId: org_id }, ); return { @@ -65,7 +65,7 @@ export function registerPageTools(server: McpServer) { async ({ org_id, site_id, page_slug }) => { const token = await getToken(); const page = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_slug)}/publish`, + `/sites/${pathSegment(site_id)}/pages/${pathSegment(page_slug)}/publish`, { token, orgId: org_id, method: 'POST' }, ); return { @@ -89,7 +89,7 @@ export function registerPageTools(server: McpServer) { }, async ({ org_id, site_id, slug, title }) => { const token = await getToken(); - const page = await growthRequest(`/sites/${encodeURIComponent(site_id)}/pages`, { + const page = await growthRequest(`/sites/${pathSegment(site_id)}/pages`, { token, orgId: org_id, method: 'POST', @@ -114,7 +114,7 @@ export function registerPageTools(server: McpServer) { const token = await getToken(); const body = buildUpdatePageBody(fields); const page = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_slug)}`, + `/sites/${pathSegment(site_id)}/pages/${pathSegment(page_slug)}`, { token, orgId: org_id, method: 'PATCH', body }, ); return { @@ -133,15 +133,12 @@ export function registerPageTools(server: McpServer) { }, async ({ org_id, site_id, url }) => { const token = await getToken(); - const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/pages/discover`, - { - token, - orgId: org_id, - method: 'POST', - body: { url }, - }, - ); + const result = await growthRequest(`/sites/${pathSegment(site_id)}/pages/discover`, { + token, + orgId: org_id, + method: 'POST', + body: { url }, + }); return { content: [{ type: 'text' as const, text: toToolText(result) }], }; @@ -167,15 +164,12 @@ export function registerPageTools(server: McpServer) { }, async ({ org_id, site_id, pages }) => { const token = await getToken(); - const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/pages/import`, - { - token, - orgId: org_id, - method: 'POST', - body: { pages }, - }, - ); + const result = await growthRequest(`/sites/${pathSegment(site_id)}/pages/import`, { + token, + orgId: org_id, + method: 'POST', + body: { pages }, + }); return { content: [{ type: 'text' as const, text: toToolText(result) }], }; diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/releases.ts b/mcp/packages/ferrgrowth-mcp/src/tools/releases.ts index 26397c3..72a83d7 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/releases.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/releases.ts @@ -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 Release { id: string; @@ -23,7 +23,7 @@ export function registerReleaseTools(server: McpServer) { async ({ org_id, site_id, release_id }) => { const token = await getToken(); const release = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/releases/${encodeURIComponent(release_id)}`, + `/sites/${pathSegment(site_id)}/releases/${pathSegment(release_id)}`, { token, orgId: org_id }, ); return { @@ -41,10 +41,10 @@ export function registerReleaseTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const releases = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/releases`, - { token, orgId: org_id }, - ); + const releases = await growthRequest(`/sites/${pathSegment(site_id)}/releases`, { + token, + orgId: org_id, + }); return { content: [{ type: 'text' as const, text: toToolText(releases) }], }; @@ -62,7 +62,7 @@ export function registerReleaseTools(server: McpServer) { async ({ org_id, site_id, release_id }) => { const token = await getToken(); const release = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/releases/${encodeURIComponent(release_id)}/activate`, + `/sites/${pathSegment(site_id)}/releases/${pathSegment(release_id)}/activate`, { token, orgId: org_id, method: 'POST' }, ); return { @@ -82,7 +82,7 @@ export function registerReleaseTools(server: McpServer) { async ({ org_id, site_id, release_id }) => { const token = await getToken(); const result = await growthRequest<{ restored_pages: number }>( - `/sites/${encodeURIComponent(site_id)}/releases/${encodeURIComponent(release_id)}/restore`, + `/sites/${pathSegment(site_id)}/releases/${pathSegment(release_id)}/restore`, { token, orgId: org_id, method: 'POST' }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/seo-bulk.ts b/mcp/packages/ferrgrowth-mcp/src/tools/seo-bulk.ts index 712fd41..ab775e7 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/seo-bulk.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/seo-bulk.ts @@ -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 RunAllSeoResult { enqueued: number; @@ -61,7 +61,7 @@ export function registerSeoBulkTools(server: McpServer) { async ({ org_id, site_id, stale_after_days, strategy }) => { const token = await getToken(); const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/audits/seo/run-all`, + `/sites/${pathSegment(site_id)}/audits/seo/run-all`, { token, orgId: org_id, method: 'POST', body: { stale_after_days, strategy } }, ); return text(result); @@ -78,7 +78,7 @@ export function registerSeoBulkTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const status = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/audits/seo/queue-status`, + `/sites/${pathSegment(site_id)}/audits/seo/queue-status`, { token, orgId: org_id }, ); return text(status); @@ -95,7 +95,7 @@ export function registerSeoBulkTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/audits/seo/queue-status/cancel`, + `/sites/${pathSegment(site_id)}/audits/seo/queue-status/cancel`, { token, orgId: org_id, method: 'POST' }, ); return text(result); diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/seo-toolkit.ts b/mcp/packages/ferrgrowth-mcp/src/tools/seo-toolkit.ts index be84871..a5abf0b 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/seo-toolkit.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/seo-toolkit.ts @@ -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 SeoRedirect { id: string; @@ -26,7 +26,7 @@ const siteSlug = z.string().min(1).describe('Site slug'); const pageSlug = z.string().min(1).describe('Page slug, e.g. "index" or "docs/start"'); function seoPath(site: string, ...segments: string[]): string { - return [`/sites/${encodeURIComponent(site)}/seo`, ...segments.map(encodeURIComponent)].join('/'); + return [`/sites/${pathSegment(site)}/seo`, ...segments.map(pathSegment)].join('/'); } function text(value: unknown) { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/seo.ts b/mcp/packages/ferrgrowth-mcp/src/tools/seo.ts index 4fb7747..6f8bbf6 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/seo.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/seo.ts @@ -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'; type SeoStrategy = 'mobile' | 'desktop'; @@ -27,7 +27,7 @@ interface SeoOverviewEntry { } function pageAuditPath(siteId: string, pageSlug: string): string { - return `/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageSlug)}/audits/seo`; + return `/sites/${pathSegment(siteId)}/pages/${pathSegment(pageSlug)}/audits/seo`; } export function registerSeoTools(server: McpServer) { @@ -41,7 +41,7 @@ export function registerSeoTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const overview = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/audits/seo/overview`, + `/sites/${pathSegment(site_id)}/audits/seo/overview`, { token, orgId: org_id }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/server-tokens.ts b/mcp/packages/ferrgrowth-mcp/src/tools/server-tokens.ts index ed70f39..4c7426f 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/server-tokens.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/server-tokens.ts @@ -6,7 +6,7 @@ import { secretRevealRefusal, toToolText, } from '@ferrlabs/mcp-core'; -import { growthRequest, orgIdSchema } from '../api-base.js'; +import { growthRequest, orgIdSchema, pathSegment } from '../api-base.js'; interface ServerToken { id: string; @@ -36,7 +36,7 @@ export function registerServerTokenTools(server: McpServer) { async ({ org_id, site_id }) => { const token = await getToken(); const tokens = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/server-tokens`, + `/sites/${pathSegment(site_id)}/server-tokens`, { token, orgId: org_id }, ); return { @@ -62,7 +62,7 @@ export function registerServerTokenTools(server: McpServer) { const token = await getToken(); const created = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/server-tokens`, + `/sites/${pathSegment(site_id)}/server-tokens`, { token, orgId: org_id, method: 'POST', body: { label } }, ); const { token: secret, ...meta } = created; @@ -93,7 +93,7 @@ export function registerServerTokenTools(server: McpServer) { async ({ org_id, site_id, token_id }) => { const token = await getToken(); await growthRequest( - `/sites/${encodeURIComponent(site_id)}/server-tokens/${encodeURIComponent(token_id)}`, + `/sites/${pathSegment(site_id)}/server-tokens/${pathSegment(token_id)}`, { token, orgId: org_id, method: 'DELETE' }, ); return { diff --git a/mcp/packages/ferrgrowth-mcp/src/tools/sites.ts b/mcp/packages/ferrgrowth-mcp/src/tools/sites.ts index 33086f0..8b176a7 100644 --- a/mcp/packages/ferrgrowth-mcp/src/tools/sites.ts +++ b/mcp/packages/ferrgrowth-mcp/src/tools/sites.ts @@ -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'; import { updateSiteFields } from './site-update-schema.js'; interface Site { @@ -36,7 +36,7 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const site = await growthRequest(`/sites/${encodeURIComponent(site_id)}`, { + const site = await growthRequest(`/sites/${pathSegment(site_id)}`, { token, orgId: org_id, }); @@ -93,7 +93,7 @@ export function registerSiteTools(server: McpServer) { for (const [k, v] of Object.entries(patch)) { if (v !== undefined) body[k] = v; } - const site = await growthRequest(`/sites/${encodeURIComponent(site_id)}`, { + const site = await growthRequest(`/sites/${pathSegment(site_id)}`, { token, orgId: org_id, method: 'PATCH', @@ -114,7 +114,7 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - await growthRequest(`/sites/${encodeURIComponent(site_id)}`, { + await growthRequest(`/sites/${pathSegment(site_id)}`, { token, orgId: org_id, method: 'DELETE', @@ -134,7 +134,7 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const site = await growthRequest(`/sites/${encodeURIComponent(site_id)}/restore`, { + const site = await growthRequest(`/sites/${pathSegment(site_id)}/restore`, { token, orgId: org_id, method: 'POST', @@ -159,7 +159,7 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id, domain }) => { const token = await getToken(); - const result = await growthRequest(`/sites/${encodeURIComponent(site_id)}/domain`, { + const result = await growthRequest(`/sites/${pathSegment(site_id)}/domain`, { token, orgId: org_id, method: 'POST', @@ -180,10 +180,11 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - const result = await growthRequest( - `/sites/${encodeURIComponent(site_id)}/domain/verify`, - { token, orgId: org_id, method: 'POST' }, - ); + const result = await growthRequest(`/sites/${pathSegment(site_id)}/domain/verify`, { + token, + orgId: org_id, + method: 'POST', + }); return { content: [{ type: 'text' as const, text: toToolText(result) }], }; @@ -199,7 +200,7 @@ export function registerSiteTools(server: McpServer) { }, async ({ org_id, site_id }) => { const token = await getToken(); - await growthRequest(`/sites/${encodeURIComponent(site_id)}/domain`, { + await growthRequest(`/sites/${pathSegment(site_id)}/domain`, { token, orgId: org_id, method: 'DELETE',