Skip to content

Commit c65eb9f

Browse files
authored
feat(cms): add getResources MCP tool for markdown resources (#43)
1 parent 5d44cc3 commit c65eb9f

4 files changed

Lines changed: 162 additions & 0 deletions

File tree

cms/next.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ const nextConfig: NextConfig = {
55
experimental: {
66
turbopackFileSystemCacheForDev: true,
77
},
8+
// The `getResources` MCP tool reads markdown files from `cms/resources/` at runtime
9+
// (see `src/mcp/resources.ts`). The directory is scanned dynamically, so static
10+
// tracing can't detect it — force-include the files into the serverless function
11+
// that serves Payload's API (and thus the MCP endpoint) under `api/[...slug]`.
12+
outputFileTracingIncludes: {
13+
'/api/[...slug]': ['./resources/**/*.md'],
14+
},
815
redirects: async () => [
916
{
1017
destination: '/admin',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
---
2+
title: Writing Style & Tonality Guide
3+
description: Voice, structure, formatting, linking and SEO rules for JHB Software articles (en/de).
4+
---
5+
16
# Writing Style & Tonality Guide — JHB Software Articles
27

38
For articles in the `articles` collection, in both `en` and `de`.

cms/src/mcp/resources.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { readdir, readFile } from 'fs/promises'
2+
import { join } from 'path'
3+
4+
// Markdown resources (e.g. the writing style guide) live as plain `.md` files in
5+
// `cms/resources/` so they stay versioned and browsable in Git. They are read at
6+
// runtime and exposed through the `getResources` MCP tool.
7+
//
8+
// `process.cwd()` is the cms project root both locally and in the Vercel serverless
9+
// function. The files are not statically reachable (we scan the directory at runtime),
10+
// so they are force-included into the function bundle via `outputFileTracingIncludes`
11+
// in `next.config.ts` — keep that entry in sync with this directory.
12+
const RESOURCES_DIR = join(process.cwd(), 'resources')
13+
14+
// Only simple, lowercase-kebab slugs are valid. Enforced before building a file path
15+
// to prevent path traversal, since the slug originates from an MCP tool argument.
16+
const SLUG_PATTERN = /^[a-z0-9-]+$/
17+
18+
export type ResourceMeta = {
19+
slug: string
20+
title: string
21+
description: string
22+
}
23+
24+
export type Resource = ResourceMeta & {
25+
content: string
26+
}
27+
28+
/**
29+
* Minimal YAML front matter parser. Handles the simple `key: value` scalars we use
30+
* (`title`, `description`) — not nested structures. Returns the parsed key/value map
31+
* and the markdown body with the front matter block stripped.
32+
*/
33+
function parseFrontMatter(raw: string): { data: Record<string, string>; body: string } {
34+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
35+
if (!match) return { body: raw, data: {} }
36+
37+
const data: Record<string, string> = {}
38+
for (const line of match[1].split(/\r?\n/)) {
39+
const separator = line.indexOf(':')
40+
if (separator === -1) continue
41+
const key = line.slice(0, separator).trim()
42+
if (!key) continue
43+
// Strip surrounding single/double quotes from the value.
44+
const value = line
45+
.slice(separator + 1)
46+
.trim()
47+
.replace(/^['"]|['"]$/g, '')
48+
data[key] = value
49+
}
50+
51+
return { body: raw.slice(match[0].length), data }
52+
}
53+
54+
function toMeta(slug: string, data: Record<string, string>): ResourceMeta {
55+
return {
56+
description: data.description ?? '',
57+
slug,
58+
title: data.title ?? slug,
59+
}
60+
}
61+
62+
/** Lists all available resources with their front matter metadata. */
63+
export async function listResources(): Promise<ResourceMeta[]> {
64+
let entries: string[]
65+
try {
66+
entries = await readdir(RESOURCES_DIR)
67+
} catch {
68+
return []
69+
}
70+
71+
const slugs = entries.filter((name) => name.endsWith('.md')).map((name) => name.slice(0, -3))
72+
73+
const resources = await Promise.all(
74+
slugs.map(async (slug) => {
75+
const raw = await readFile(join(RESOURCES_DIR, `${slug}.md`), 'utf-8')
76+
return toMeta(slug, parseFrontMatter(raw).data)
77+
}),
78+
)
79+
80+
return resources.sort((a, b) => a.slug.localeCompare(b.slug))
81+
}
82+
83+
/**
84+
* Reads a single resource by slug, returning its metadata and markdown body (front
85+
* matter stripped). Returns `null` for an invalid or unknown slug.
86+
*/
87+
export async function readResource(slug: string): Promise<Resource | null> {
88+
if (!SLUG_PATTERN.test(slug)) return null
89+
90+
let raw: string
91+
try {
92+
raw = await readFile(join(RESOURCES_DIR, `${slug}.md`), 'utf-8')
93+
} catch {
94+
return null
95+
}
96+
97+
const { body, data } = parseFrontMatter(raw)
98+
return { ...toMeta(slug, data), content: body.trim() }
99+
}

cms/src/payload.config.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import Footer from './globals/Footer'
6464
import Header from './globals/Header'
6565
import Labels from './globals/Labels'
6666
import { mcpOverrideAuth } from './mcp/overrideAuth'
67+
import { listResources, readResource } from './mcp/resources'
6768
import { authenticated } from './shared/access/authenticated'
6869
import { CollectionGroups } from './shared/CollectionGroups'
6970
import { customTranslations } from './shared/customTranslations'
@@ -173,6 +174,15 @@ const getPageUrlParameters = {
173174
.describe('Whether to generate a preview URL. Defaults to true.'),
174175
} satisfies z.ZodRawShape
175176

177+
const getResourcesParameters = {
178+
slug: z
179+
.string()
180+
.optional()
181+
.describe(
182+
'Slug of a single resource (the markdown filename without its `.md` extension). Omit to list all available resources.',
183+
),
184+
} satisfies z.ZodRawShape
185+
176186
// Lifted out of the `buildConfig` literal and annotated with the concrete
177187
// `MCPPluginConfig` type so the tool definition is type-checked directly against
178188
// the plugin's contract and keeps the `buildConfig` call readable.
@@ -283,6 +293,47 @@ const mcpPluginConfig: MCPPluginConfig = {
283293
name: 'getPageUrl',
284294
parameters: getPageUrlParameters as unknown as MCPToolParameters,
285295
},
296+
// The MCP spec has a native "resources" primitive, and this plugin's server
297+
// exposes it — but claude.ai (a primary client here) only supports MCP *tools*,
298+
// not resources. So we surface our markdown resources through this custom tool
299+
// instead. The files live in `cms/resources/` (see `./mcp/resources.ts`), which
300+
// keeps them available in Git and over MCP from a single source.
301+
{
302+
description:
303+
'Read project resources stored as markdown (e.g. the writing style & tonality guide). Without a `slug`, returns the list of available resources as JSON (`{ slug, title, description }`). With a `slug`, returns the full markdown content of that resource.',
304+
handler: async (args) => {
305+
const text = (t: string) => ({ content: [{ text: t, type: 'text' as const }] })
306+
try {
307+
const { slug } = z.object(getResourcesParameters).parse(args)
308+
309+
if (slug === undefined) {
310+
return text(JSON.stringify(await listResources()))
311+
}
312+
313+
const resource = await readResource(slug)
314+
if (!resource) {
315+
const available = await listResources()
316+
return text(
317+
JSON.stringify({
318+
availableSlugs: available.map((r) => r.slug),
319+
error: `No resource with slug "${slug}".`,
320+
}),
321+
)
322+
}
323+
324+
return text(resource.content)
325+
} catch (err) {
326+
return text(
327+
JSON.stringify({
328+
details: err instanceof Error ? err.message : String(err),
329+
error: 'Failed to read resource',
330+
}),
331+
)
332+
}
333+
},
334+
name: 'getResources',
335+
parameters: getResourcesParameters as unknown as MCPToolParameters,
336+
},
286337
],
287338
},
288339
overrideAuth: (req, getDefault) => mcpOverrideAuth(req, getDefault),

0 commit comments

Comments
 (0)