From 90e98a07091f25fca5eaa8d663b27875e85be4cd Mon Sep 17 00:00:00 2001 From: Brendan Irvine-Broque Date: Sat, 20 Jun 2026 19:56:49 -0700 Subject: [PATCH] Add skills resources to Cloudflare API code mode server --- src/server.ts | 2 + src/skills.ts | 453 +++++++++++++++++++++++++++++++++++++++++++ tests/skills.test.ts | 309 +++++++++++++++++++++++++++++ 3 files changed, 764 insertions(+) create mode 100644 src/skills.ts create mode 100644 tests/skills.test.ts diff --git a/src/server.ts b/src/server.ts index c3f63c5..e00fa1f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import { registerSearchTool } from './tools/search' import { registerExecuteTool } from './tools/execute' import { attachMetrics } from './metrics' import { SERVER_INFO } from './constants' +import { registerCloudflareSkills } from './skills' import type { AuthProps } from './auth/types' export async function createServer(props: AuthProps, codemode = true): Promise { @@ -17,6 +18,7 @@ export async function createServer(props: AuthProps, codemode = true): Promise & { + name: string + description: string +} + +type SkillArchiveEntry = { + url: string + mimeType: string + digest: string +} + +type SkillIndex = { + skills: Array<{ + url?: string + digest?: string + frontmatter: SkillFrontmatter + archives?: SkillArchiveEntry[] + }> +} + +type TreeDirectoryEntry = { + mimeType: 'inode/directory' + name: string + path: string + type: 'directory' + uri: string +} + +type TreeFileEntry = { + description?: string + digest: string + mimeType: string + name: string + path: string + size: number + type: 'file' + uri: string + _meta?: Record +} + +type TreeEntry = TreeDirectoryEntry | TreeFileEntry + +type SkillTree = { + entries: TreeEntry[] +} + +type CacheEntry = { + expiresAt: number + value: T +} + +export type CloudflareSkillsProviderOptions = { + baseUrl?: string + fetcher?: Fetcher + cacheTtlMs?: number +} + +export class CloudflareSkillsProvider { + private readonly baseUrl: string + private readonly fetcher: Fetcher + private readonly cacheTtlMs: number + private indexCache?: CacheEntry + private treeCache?: CacheEntry + private readonly fileCache = new Map>() + + constructor(options: CloudflareSkillsProviderOptions = {}) { + this.baseUrl = (options.baseUrl ?? DEFAULT_SKILLS_BASE_URL).replace(/\/+$/, '') + this.fetcher = options.fetcher ?? fetch + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS + } + + async readIndex(uri = INDEX_URI): Promise { + const index = await this.getIndex() + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(index, null, 2) + } + ] + } + } + + async readResource(uri: string): Promise { + const skillPath = parseSkillUriPath(uri) + if (skillPath === 'index.json') { + return this.readIndex(uri) + } + if (skillPath === TREE_ARTIFACT) { + throw invalidParams(`Resource ${uri} not found`) + } + + const archive = await this.getArchiveEntry(skillPath) + const resource = archive ?? (await this.getFileEntry(skillPath, uri)) + const bytes = await this.fetchFile(skillPath) + const content = { + uri, + mimeType: resource.mimeType, + ...(isTextMimeType(resource.mimeType) + ? { text: new TextDecoder().decode(bytes) } + : { blob: uint8ArrayToBase64(bytes) }) + } + + return { contents: [content] } + } + + async readDirectory(uri: string, cursor?: string): Promise { + const skillPath = parseSkillUriPath(uri) + if (skillPath === 'index.json' || skillPath === TREE_ARTIFACT) { + throw invalidParams(`Directory ${uri} not found`) + } + + const tree = await this.getTree() + const directory = tree.entries.find( + (entry) => entry.type === 'directory' && entry.path === skillPath + ) + if (!directory) { + throw invalidParams(`Directory ${uri} not found`) + } + + const childPrefix = `${directory.path}/` + const childEntries = tree.entries + .filter((entry) => entry.path.startsWith(childPrefix)) + .filter((entry) => !entry.path.slice(childPrefix.length).includes('/')) + .sort((a, b) => a.path.localeCompare(b.path)) + + return paginateResources(childEntries.map(resourceForTreeEntry), cursor) + } + + private async getIndex(): Promise { + const cached = getFreshCacheValue(this.indexCache) + if (cached) { + return cached + } + + const index = await this.fetchJson('index.json') + assertSkillIndex(index) + this.indexCache = this.cache(index) + return index + } + + private async getTree(): Promise { + const cached = getFreshCacheValue(this.treeCache) + if (cached) { + return cached + } + + const tree = await this.fetchJson(TREE_ARTIFACT) + assertSkillTree(tree) + this.treeCache = this.cache(tree) + return tree + } + + private async getFileEntry(skillPath: string, uri: string): Promise { + const tree = await this.getTree() + const entry = tree.entries.find( + (item): item is TreeFileEntry => item.type === 'file' && item.path === skillPath + ) + if (!entry) { + throw invalidParams(`Resource ${uri} not found`) + } + return entry + } + + private async getArchiveEntry(skillPath: string): Promise { + const index = await this.getIndex() + for (const skill of index.skills) { + for (const archive of skill.archives ?? []) { + try { + if (parseSkillUriPath(archive.url) === skillPath) { + return archive + } + } catch { + continue + } + } + } + return undefined + } + + private async fetchJson(artifactPath: string): Promise { + const response = await this.fetcher(this.artifactUrl(artifactPath), { + headers: { Accept: 'application/json' } + }) + if (!response.ok) { + throw new Error( + `Failed to fetch Cloudflare skills artifact ${artifactPath}: ${response.status}` + ) + } + + return (await response.json()) as T + } + + private async fetchFile(publicPath: string): Promise { + const cached = getFreshCacheValue(this.fileCache.get(publicPath)) + if (cached) { + return cached + } + + const response = await this.fetcher(this.artifactUrl(publicPath)) + if (!response.ok) { + throw new Error(`Failed to fetch Cloudflare skill file ${publicPath}: ${response.status}`) + } + + const bytes = new Uint8Array(await response.arrayBuffer()) + this.fileCache.set(publicPath, this.cache(bytes)) + return bytes + } + + private artifactUrl(publicPath: string) { + const encodedPath = publicPath.split('/').map(encodeURIComponent).join('/') + return `${this.baseUrl}/${encodedPath}` + } + + private cache(value: T): CacheEntry { + return { + value, + expiresAt: Date.now() + this.cacheTtlMs + } + } +} + +const defaultCloudflareSkillsProvider = new CloudflareSkillsProvider() + +export function registerCloudflareSkills( + server: McpServer, + provider = defaultCloudflareSkillsProvider +) { + server.server.registerCapabilities({ + extensions: { + [SKILLS_EXTENSION_ID]: { directoryRead: true } + } + } as never) + + server.registerResource( + 'cloudflare-skills-index', + INDEX_URI, + { + title: 'Cloudflare Agent Skills index', + description: 'Index of Cloudflare Agent Skills served from developers.cloudflare.com.', + mimeType: 'application/json' + }, + (uri) => provider.readIndex(uri.toString()) + ) + + server.registerResource( + 'cloudflare-skills', + new ResourceTemplate('skill://{+path}', { list: undefined }), + { + title: 'Cloudflare Agent Skills', + description: 'Cloudflare Agent Skill files served from developers.cloudflare.com.' + }, + (uri) => provider.readResource(uri.toString()) + ) + + server.server.setRequestHandler(DirectoryReadRequestSchema, (request) => + provider.readDirectory(request.params.uri, request.params.cursor) + ) +} + +function resourceForTreeEntry(entry: TreeEntry): Resource { + return { + uri: entry.uri, + name: entry.name, + mimeType: entry.mimeType, + ...(entry.type === 'file' && entry.description ? { description: entry.description } : {}), + ...(entry.type === 'file' && entry._meta ? { _meta: entry._meta } : {}) + } +} + +function parseSkillUriPath(uri: string) { + if (!uri.startsWith(SKILL_URI_PREFIX)) { + throw invalidParams(`Unsupported skill URI ${uri}`) + } + const withoutScheme = uri.slice(SKILL_URI_PREFIX.length) + if (withoutScheme.includes('?') || withoutScheme.includes('#')) { + throw invalidParams(`Unsupported skill URI ${uri}`) + } + + const rawPath = withoutScheme.replace(/^\/+/, '') + const segments = rawPath.split('/').filter((segment) => segment.length > 0) + if (segments.some((segment) => segment === '.' || segment === '..') || segments.length === 0) { + throw invalidParams(`Unsupported skill URI ${uri}`) + } + + let decodedSegments: string[] + try { + decodedSegments = segments.map((segment) => decodeURIComponent(segment)) + } catch { + throw invalidParams(`Unsupported skill URI ${uri}`) + } + + if ( + decodedSegments.some( + (segment) => + segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\') + ) + ) { + throw invalidParams(`Unsupported skill URI ${uri}`) + } + + return decodedSegments.join('/') +} + +function isTextMimeType(mimeType: string) { + const baseMimeType = mimeType.split(';')[0].trim().toLowerCase() + return ( + baseMimeType.startsWith('text/') || + baseMimeType === 'application/json' || + baseMimeType === 'application/javascript' || + baseMimeType === 'image/svg+xml' + ) +} + +function uint8ArrayToBase64(bytes: Uint8Array) { + let binary = '' + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.slice(i, i + chunkSize)) + } + return btoa(binary) +} + +function paginateResources(resources: Resource[], cursor?: string): ListResourcesResult { + const start = cursor ? Number.parseInt(cursor, 10) : 0 + if (!Number.isInteger(start) || start < 0) { + throw invalidParams(`Invalid cursor ${cursor}`) + } + + const page = resources.slice(start, start + DIRECTORY_PAGE_SIZE) + const nextCursor = + start + DIRECTORY_PAGE_SIZE < resources.length ? String(start + DIRECTORY_PAGE_SIZE) : undefined + + return { + resources: page, + ...(nextCursor ? { nextCursor } : {}) + } +} + +function getFreshCacheValue(entry: CacheEntry | undefined) { + if (entry && entry.expiresAt > Date.now()) { + return entry.value + } + return undefined +} + +function assertSkillIndex(value: SkillIndex) { + if (!value || !Array.isArray(value.skills)) { + throw new Error('Cloudflare skills index response was invalid') + } + + for (const skill of value.skills) { + if ( + !skill || + !skill.frontmatter || + typeof skill.frontmatter.name !== 'string' || + typeof skill.frontmatter.description !== 'string' + ) { + throw new Error('Cloudflare skills index response was invalid') + } + + if ( + (skill.url !== undefined && typeof skill.url !== 'string') || + (skill.digest !== undefined && typeof skill.digest !== 'string') + ) { + throw new Error('Cloudflare skills index response was invalid') + } + + if ( + skill.archives !== undefined && + (!Array.isArray(skill.archives) || + skill.archives.some( + (archive) => + !archive || + typeof archive.url !== 'string' || + typeof archive.mimeType !== 'string' || + typeof archive.digest !== 'string' + )) + ) { + throw new Error('Cloudflare skills index response was invalid') + } + } +} + +function assertSkillTree(value: SkillTree) { + if (!value || !Array.isArray(value.entries)) { + throw new Error('Cloudflare skills tree response was invalid') + } + + for (const entry of value.entries) { + if ( + !entry || + typeof entry.path !== 'string' || + typeof entry.uri !== 'string' || + typeof entry.name !== 'string' || + typeof entry.mimeType !== 'string' + ) { + throw new Error('Cloudflare skills tree response was invalid') + } + + if (entry.type === 'file') { + if ( + typeof entry.digest !== 'string' || + typeof entry.size !== 'number' || + !entry.uri.startsWith(SKILL_URI_PREFIX) + ) { + throw new Error('Cloudflare skills tree response was invalid') + } + continue + } + + if (entry.type !== 'directory' || entry.mimeType !== 'inode/directory') { + throw new Error('Cloudflare skills tree response was invalid') + } + } +} + +function invalidParams(message: string) { + return new McpError(ErrorCode.InvalidParams, message) +} diff --git a/tests/skills.test.ts b/tests/skills.test.ts new file mode 100644 index 0000000..6b2962d --- /dev/null +++ b/tests/skills.test.ts @@ -0,0 +1,309 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { afterEach, describe, expect, it } from 'vitest' +import { createServer } from '../src/server' +import { CloudflareSkillsProvider, registerCloudflareSkills } from '../src/skills' +import { clearSpec, seedSpec } from './helpers/spec' + +import type { AuthProps } from '../src/auth/types' +import type { ListResourcesResult } from '@modelcontextprotocol/sdk/types.js' +import type { OperationInfo } from '../src/openapi' + +const skillsBaseUrl = 'https://developers.cloudflare.com/.well-known/mcp/skills' + +const skillText = `--- +name: cloudflare +description: Build on Cloudflare. +references: + - workers + - d1 +--- + +# Cloudflare +` + +const referenceText = '# Workers reference\n' +const archiveBytes = Uint8Array.from([0x1f, 0x8b, 0x08, 0x00]) +const archiveBlob = 'H4sIAA==' + +const skillFrontmatter = { + name: 'cloudflare', + description: 'Build on Cloudflare.', + references: ['workers', 'd1'] +} + +const skillDigest = 'sha256:3030303030303030303030303030303030303030303030303030303030303030' + +const publishedIndex = { + skills: [ + { + url: 'skill://cloudflare/SKILL.md', + digest: skillDigest, + frontmatter: skillFrontmatter, + archives: [ + { + url: 'skill://cloudflare.tar.gz', + mimeType: 'application/gzip', + digest: 'sha256:3232323232323232323232323232323232323232323232323232323232323232' + } + ] + } + ] +} + +const publishedTree = { + entries: [ + { + mimeType: 'inode/directory', + name: 'cloudflare', + path: 'cloudflare', + type: 'directory', + uri: 'skill://cloudflare' + }, + { + description: 'Build on Cloudflare.', + digest: skillDigest, + mimeType: 'text/markdown', + name: 'cloudflare', + path: 'cloudflare/SKILL.md', + size: skillText.length, + type: 'file', + uri: 'skill://cloudflare/SKILL.md', + _meta: { + 'io.modelcontextprotocol.skills/frontmatter': skillFrontmatter + } + }, + { + mimeType: 'inode/directory', + name: 'references', + path: 'cloudflare/references', + type: 'directory', + uri: 'skill://cloudflare/references' + }, + { + digest: 'sha256:3131313131313131313131313131313131313131313131313131313131313131', + mimeType: 'text/markdown', + name: 'workers.md', + path: 'cloudflare/references/workers.md', + size: referenceText.length, + type: 'file', + uri: 'skill://cloudflare/references/workers.md' + } + ] +} + +const authProps: AuthProps = { + type: 'account_token', + accessToken: 'test-token', + account: { id: 'test-account', name: 'Test Account' } +} + +afterEach(() => clearSpec()) + +function createProvider() { + const fetcher: typeof fetch = async (input) => { + const url = input.toString() + if (url === `${skillsBaseUrl}/index.json`) { + return Response.json(publishedIndex) + } + if (url === `${skillsBaseUrl}/.tree.json`) { + return Response.json(publishedTree) + } + if (url === `${skillsBaseUrl}/cloudflare/SKILL.md`) { + return new Response(skillText) + } + if (url === `${skillsBaseUrl}/cloudflare/references/workers.md`) { + return new Response(referenceText) + } + if (url === `${skillsBaseUrl}/cloudflare.tar.gz`) { + return new Response(archiveBytes) + } + return new Response('not found', { status: 404 }) + } + + return new CloudflareSkillsProvider({ baseUrl: skillsBaseUrl, fetcher, cacheTtlMs: 0 }) +} + +async function withClient( + server: McpServer, + action: (client: Client) => Promise +): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + try { + return await action(client) + } finally { + await client.close() + await server.close() + } +} + +describe('CloudflareSkillsProvider', () => { + it('serves the published SEP-2640 skill index', async () => { + const provider = createProvider() + const result = await provider.readIndex() + const content = result.contents[0] + if (!('text' in content)) { + throw new Error('Expected text content') + } + const index = JSON.parse(content.text) + + expect(index).toEqual(publishedIndex) + }) + + it('reads published skill files and supporting files through skill URIs', async () => { + const provider = createProvider() + const skill = await provider.readResource('skill://cloudflare/SKILL.md') + const reference = await provider.readResource('skill://cloudflare/references/workers.md') + + expect(skill.contents[0]).toMatchObject({ + uri: 'skill://cloudflare/SKILL.md', + mimeType: 'text/markdown', + text: skillText + }) + expect(reference.contents[0]).toMatchObject({ + uri: 'skill://cloudflare/references/workers.md', + mimeType: 'text/markdown', + text: referenceText + }) + }) + + it('reads archive resources advertised by the published index', async () => { + const provider = createProvider() + const archive = await provider.readResource('skill://cloudflare.tar.gz') + + expect(archive.contents[0]).toMatchObject({ + uri: 'skill://cloudflare.tar.gz', + mimeType: 'application/gzip', + blob: archiveBlob + }) + }) + + it('lists direct children from the published tree manifest', async () => { + const provider = createProvider() + const result = await provider.readDirectory('skill://cloudflare') + + expect(result.resources).toEqual([ + { + uri: 'skill://cloudflare/references', + name: 'references', + mimeType: 'inode/directory' + }, + { + uri: 'skill://cloudflare/SKILL.md', + name: 'cloudflare', + description: 'Build on Cloudflare.', + mimeType: 'text/markdown', + _meta: { + 'io.modelcontextprotocol.skills/frontmatter': skillFrontmatter + } + } + ]) + }) +}) + +describe('registerCloudflareSkills', () => { + it('advertises the skills extension and registers resource handlers', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }) + registerCloudflareSkills(server, createProvider()) + + expect( + (server.server as unknown as { getCapabilities(): unknown }).getCapabilities() + ).toMatchObject({ + extensions: { + 'io.modelcontextprotocol/skills': { directoryRead: true } + }, + resources: { + listChanged: true + } + }) + + await withClient(server, async (client) => { + // SDK 1.26's client schema predates capabilities.extensions and strips it + // during parsing; the raw server capability object above still advertises it. + expect(client.getServerCapabilities()).toMatchObject({ + resources: { + listChanged: true + } + }) + + const resources = await client.listResources() + expect(resources.resources).toEqual([ + expect.objectContaining({ + uri: 'skill://index.json', + name: 'cloudflare-skills-index', + mimeType: 'application/json' + }) + ]) + + const index = await client.readResource({ uri: 'skill://index.json' }) + const content = index.contents[0] + if (!('text' in content)) { + throw new Error('Expected text content') + } + expect(JSON.parse(content.text).skills[0].url).toBe('skill://cloudflare/SKILL.md') + + const skill = await client.readResource({ uri: 'skill://cloudflare/SKILL.md' }) + expect(skill.contents[0]).toMatchObject({ + uri: 'skill://cloudflare/SKILL.md', + mimeType: 'text/markdown', + text: skillText + }) + }) + }) + + it('registers the resources/directory/read extension method', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }) + registerCloudflareSkills(server, createProvider()) + + const handlers = ( + server.server as unknown as { + _requestHandlers: Map< + string, + (request: unknown, extra: unknown) => Promise + > + } + )._requestHandlers + + const result = await handlers.get('resources/directory/read')?.( + { + method: 'resources/directory/read', + params: { uri: 'skill://cloudflare/references' } + }, + {} + ) + + expect(result).toEqual({ + resources: [ + { + uri: 'skill://cloudflare/references/workers.md', + name: 'workers.md', + mimeType: 'text/markdown' + } + ] + }) + }) +}) + +describe('createServer skills registration', () => { + it('registers Cloudflare skills only for code mode', async () => { + await seedSpec({ + '/accounts/{account_id}/workers/scripts': { + get: { summary: 'List Workers' } as OperationInfo + } + }) + + const codeModeServer = await createServer(authProps) + const nonCodeModeServer = await createServer(authProps, false) + + const codeModeResources = (codeModeServer as unknown as { _registeredResources: object }) + ._registeredResources + const nonCodeModeResources = (nonCodeModeServer as unknown as { _registeredResources: object }) + ._registeredResources + + expect(codeModeResources).toHaveProperty('skill://index.json') + expect(nonCodeModeResources).not.toHaveProperty('skill://index.json') + }) +})