diff --git a/README.md b/README.md index e5ca67d..6b0728d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Let your users do this: `npx skills add https://your-website-here.com/` Bundle [Agent Skills](https://agentskills.io/) into your Astro site, for others to consume by URL. This integration implements the [Agent Skills Discovery RFC](https://github.com/elithrar/agent-skills-discovery-rfc), allowing AI agents to discover and use skills published on your website. -- Automatically generates your `/.well-known/skills/index.json` index file. +- Automatically generates your `/.well-known/agent-skills/index.json` index file. - Validates your skills, frontmatter, etc. for compliance. - Designed for Astro [Content Collections](https://docs.astro.build/en/guides/content-collections/). @@ -36,6 +36,44 @@ export default defineConfig({ }); ``` +### Experimental MCP/SEP Skill Resources + +To also publish skills as static MCP resource artifacts, enable the experimental MCP mode: + +```ts +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import skills from 'astro-skills'; + +export default defineConfig({ + integrations: [ + skills({ + mcp: { + prefix: '/.well-known/mcp/skills', + resourceBase: 'skill://', + directoryManifest: true, + archives: true, + }, + }), + ], +}); +``` + +This generates: + +- `/.well-known/mcp/skills/index.json` +- `/.well-known/mcp/skills/.tree.json` +- Direct static routes for every file in every skill directory +- `.tar.gz` archive resources for multi-file skills + +The existing `/.well-known/agent-skills/index.json` discovery output remains enabled, so one Astro site can support both the Agent Skills well-known discovery proposal and SEP-2640/MCP resource publication at the same time. + +The MCP index follows the SEP-2640 draft shape: it uses `skill://.../SKILL.md` resource URLs, includes the raw `SKILL.md` SHA-256 digest when `url` is present, and copies the complete `SKILL.md` frontmatter into each `skills[].frontmatter` entry. Multi-file skills also include `archives[]` alternatives whose digests are computed from the generated archive bytes. + +The generated `.tree.json` file is a static-host helper, not part of SEP-2640 itself. It lists directory and file resource metadata so an MCP server can implement `resources/directory/read` without rescanning the filesystem at request time. + +During static builds, `astro-skills` also writes an `_headers` block for generated skill artifacts so hosts that support `_headers` serve JSON, Markdown, and archive files with the expected content types. + ## Configuration To get started, create a `skills/` directory in your project root with your skills: diff --git a/example/astro.config.mjs b/example/astro.config.mjs index 8e5aaa5..90b4ca6 100644 --- a/example/astro.config.mjs +++ b/example/astro.config.mjs @@ -5,5 +5,14 @@ import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ - integrations: [skills()], + integrations: [ + skills({ + mcp: { + prefix: '/.well-known/mcp/skills', + resourceBase: 'skill://', + directoryManifest: true, + archives: true, + }, + }), + ], }); diff --git a/example/package.json b/example/package.json index cefabb9..a93dd15 100644 --- a/example/package.json +++ b/example/package.json @@ -10,6 +10,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^5.0.0" + "astro": "^5.0.0", + "astro-skills": "file:.." } -} \ No newline at end of file +} diff --git a/example/skills/acme/billing/refunds/SKILL.md b/example/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 0000000..1955cb1 --- /dev/null +++ b/example/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,10 @@ +--- +name: refunds +description: Process billing refunds for Acme customers. +metadata: + owner: billing +--- + +# Refunds + +Use this skill when reviewing or processing a customer refund request. diff --git a/package.json b/package.json index 60a55c3..3a093b9 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ "exports": { ".": "./dist/index.js", "./routes/index-json": "./dist/routes/index-json.js", + "./routes/agent-skill-resource": "./dist/routes/agent-skill-resource.js", "./routes/skill-md": "./dist/routes/skill-md.js", "./routes/skill-archive": "./dist/routes/skill-archive.js", + "./routes/mcp": "./dist/routes/mcp.js", "./package.json": "./package.json" }, "files": [ diff --git a/src/index.ts b/src/index.ts index 535e91d..2beb0dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,32 @@ import type { AstroIntegration } from 'astro'; +import { resolveSkillsMcpOptions } from './mcp.js'; +import { writeStaticHeaders } from './static-headers.js'; +import type { ResolvedSkillsMcpOptions, SkillsIntegrationOptions } from './types.js'; // Re-export the loader for use in content.config.ts export { skillsLoader } from './loader.js'; // Re-export types -export type { Skill, SkillData, SkillsIndex, SkillsIndexEntry, SkillsLoaderOptions, SkillType } from './types.js'; +export type { + ResolvedSkillsMcpOptions, + Skill, + SkillData, + SkillFileData, + SkillFrontmatter, + SkillsIndex, + SkillsIndexEntry, + SkillsIntegrationOptions, + SkillsLoaderOptions, + SkillsMcpArchiveEntry, + SkillsMcpIndex, + SkillsMcpIndexEntry, + SkillsMcpOptions, + SkillsMcpTree, + SkillsMcpTreeDirectoryEntry, + SkillsMcpTreeEntry, + SkillsMcpTreeFileEntry, + SkillType, +} from './types.js'; const PKG_NAME = 'astro-skills'; @@ -42,11 +64,13 @@ const PKG_NAME = 'astro-skills'; * * @see https://agentskills.io/ */ -export default function skillsIntegration(): AstroIntegration { +export default function skillsIntegration(options: SkillsIntegrationOptions = {}): AstroIntegration { + const mcpOptions = resolveSkillsMcpOptions(options.mcp); + return { name: PKG_NAME, hooks: { - 'astro:config:setup': ({ injectRoute, logger }) => { + 'astro:config:setup': ({ injectRoute, logger, updateConfig }) => { logger.info('Setting up Agent Skills Discovery routes'); // Inject the index.json route @@ -55,20 +79,56 @@ export default function skillsIntegration(): AstroIntegration { entrypoint: 'astro-skills/routes/index-json', }); - // Inject the SKILL.md route for skill-md type skills - injectRoute({ - pattern: '/.well-known/agent-skills/[skill]/SKILL.md', - entrypoint: 'astro-skills/routes/skill-md', - }); - - // Inject the archive route for archive type skills + // Inject the resource route for skill-md and archive type skills injectRoute({ - pattern: '/.well-known/agent-skills/[skill].tar.gz', - entrypoint: 'astro-skills/routes/skill-archive', + pattern: '/.well-known/agent-skills/[...path]', + entrypoint: 'astro-skills/routes/agent-skill-resource', }); logger.info('Agent Skills Discovery routes configured'); + + if (mcpOptions) { + updateConfig({ + vite: { + plugins: [mcpConfigPlugin(mcpOptions)], + }, + }); + + injectRoute({ + pattern: `${mcpOptions.prefix}/[...path]`, + entrypoint: 'astro-skills/routes/mcp', + }); + + logger.info(`Experimental MCP Skills routes configured at ${mcpOptions.prefix}`); + } + }, + 'astro:build:done': async ({ dir, logger }) => { + const entryCount = await writeStaticHeaders(dir, { mcp: mcpOptions }); + if (entryCount > 0) { + logger.info(`Generated _headers entries for ${entryCount} skill artifact(s)`); + } }, }, }; } + +function mcpConfigPlugin(config: ResolvedSkillsMcpOptions) { + const virtualModuleId = 'astro-skills:mcp-config'; + const resolvedVirtualModuleId = `\0${virtualModuleId}`; + + return { + name: 'astro-skills:mcp-config', + resolveId(id: string) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + return undefined; + }, + load(id: string) { + if (id === resolvedVirtualModuleId) { + return `export default ${JSON.stringify(config)};`; + } + return undefined; + }, + }; +} diff --git a/src/loader.ts b/src/loader.ts index 4a7cdaf..76b6f54 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, promises as fs } from 'node:fs'; -import { dirname, relative } from 'node:path'; +import { basename, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Loader } from 'astro/loaders'; import matter from 'gray-matter'; @@ -9,22 +9,30 @@ import picomatch from 'picomatch'; import { Header, Pack, ReadEntry } from 'tar'; import { glob as tinyglobby } from 'tinyglobby'; import { skillSchema } from './schema.js'; -import type { SkillsLoaderOptions, SkillType } from './types.js'; +import type { SkillFileData, SkillFrontmatter, SkillsLoaderOptions, SkillType } from './types.js'; import { + getMimeType, getSkillNameValidationError, - isBinaryFile, - isValidSkillName, + getSkillPathValidationError, + isTextFile, normalizeFilePath, } from './utils.js'; /** - * Represents a single file within a skill (used internally during loading) + * Represents a single file while preparing tar archives. */ -interface SkillFile { - /** File content (UTF-8 string or base64-encoded for binary files) */ +type ArchiveFile = Pick; + +/** + * Represents a single file as loaded from disk. + */ +interface LoadedSkillFile extends SkillFileData { + /** File content (UTF-8 string or base64-encoded for non-text files) */ content: string; /** Encoding used for the content */ encoding: 'utf-8' | 'base64'; + /** Raw bytes for digest and archive generation */ + buffer: Buffer; } /** @@ -52,7 +60,7 @@ function sha256(data: Buffer | string): string { * Generate a tar.gz archive from a set of files. * Returns the archive as a Buffer. */ -async function generateTarGz(files: Record): Promise { +async function generateTarGz(files: Record): Promise { return new Promise((resolve, reject) => { const pack = new Pack({ gzip: true }); const chunks: Buffer[] = []; @@ -98,6 +106,39 @@ async function generateTarGz(files: Record): Promise }); } +function hasCurrentSkillDataShape(data: unknown, skillType: SkillType): boolean { + if (!isRecord(data)) return false; + if (typeof data.skillMdDigest !== 'string') return false; + if (!isRecord(data.frontmatter)) return false; + if (typeof data.frontmatter.name !== 'string') return false; + if (typeof data.frontmatter.description !== 'string') return false; + if (!Array.isArray(data.files)) return false; + if (!data.files.some((file) => isSkillFileDataLike(file) && file.path === 'SKILL.md')) { + return false; + } + if (!data.files.every(isSkillFileDataLike)) return false; + if (skillType === 'archive' && typeof data.archiveDigest !== 'string') return false; + + return true; +} + +function isSkillFileDataLike(value: unknown): value is SkillFileData { + if (!isRecord(value)) return false; + + return ( + typeof value.path === 'string' && + typeof value.content === 'string' && + (value.encoding === 'utf-8' || value.encoding === 'base64') && + typeof value.mimeType === 'string' && + typeof value.digest === 'string' && + typeof value.size === 'number' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + /** * Creates a content loader for Agent Skills. * @@ -162,17 +203,39 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { ); } + const skillDirs = skillFiles.map((skillFile) => normalizeFilePath(dirname(skillFile))); + const invalidNestedSkillDirs = new Set(); + for (const skillDir of skillDirs) { + for (const otherSkillDir of skillDirs) { + if (skillDir !== otherSkillDir && otherSkillDir.startsWith(`${skillDir}/`)) { + invalidNestedSkillDirs.add(skillDir); + invalidNestedSkillDirs.add(otherSkillDir); + logger.error( + `Nested skills are not supported: "${otherSkillDir}" is inside "${skillDir}".`, + ); + } + } + } + /** * Loads a single skill from its directory */ async function loadSkill(skillMdPath: string, oldId?: string): Promise { - const skillDir = dirname(skillMdPath); - const skillId = skillDir === '.' ? skillMdPath.replace('/SKILL.md', '') : skillDir; + const skillDir = normalizeFilePath(dirname(skillMdPath)); + if (skillDir === '.') { + logger.error('SKILL.md must live inside a skill directory.'); + return; + } + const skillId = skillDir; + if (invalidNestedSkillDirs.has(skillId)) { + return; + } - // Validate skill name - if (!isValidSkillName(skillId)) { - const error = getSkillNameValidationError(skillId); - logger.error(`Invalid skill name "${skillId}": ${error}`); + // Validate skill path. Prefix segments may organize skills, but the + // final segment must satisfy Agent Skills naming rules. + const skillPathError = getSkillPathValidationError(skillId); + if (skillPathError) { + logger.error(`Invalid skill path "${skillId}": ${skillPathError}`); return; } @@ -213,83 +276,105 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { return; } + const skillName = basename(skillId); + if (frontmatter.name !== skillName) { + logger.error( + `Skill "${skillId}" frontmatter name "${frontmatter.name}" must match final path segment "${skillName}"`, + ); + return; + } + + const skillNameError = getSkillNameValidationError(frontmatter.name); + if (skillNameError) { + logger.error(`Invalid skill name "${frontmatter.name}": ${skillNameError}`); + return; + } + const skillFrontmatter = frontmatter as SkillFrontmatter; + // Find all files in the skill directory to determine skill type const skillDirUrl = new URL(skillDir + '/', baseDir); const skillDirPath = fileURLToPath(skillDirUrl); - const allFiles = await tinyglobby('**/*', { - cwd: skillDirPath, - expandDirectories: false, - onlyFiles: true, - }); + const allFiles = ( + await tinyglobby('**/*', { + cwd: skillDirPath, + expandDirectories: false, + onlyFiles: true, + }) + ) + .map(normalizeFilePath) + .sort((a, b) => a.localeCompare(b)); + + const limit = pLimit(10); + const loadedFiles = await Promise.all( + allFiles.map((filePath) => + limit(async (): Promise => { + const fileUrl = new URL(filePath, skillDirUrl); + const fullPath = fileURLToPath(fileUrl); + + fileToSkillMap.set(fullPath, skillId); + + try { + const buffer = + normalizeFilePath(filePath) === 'SKILL.md' + ? skillMdRawBuffer + : await fs.readFile(fileUrl); + const isText = isTextFile(filePath); + const encoding = isText ? 'utf-8' : 'base64'; + const content = isText ? buffer.toString('utf-8') : buffer.toString('base64'); + + return { + path: filePath, + content, + encoding, + mimeType: getMimeType(filePath), + digest: sha256(buffer), + size: buffer.byteLength, + buffer, + }; + } catch (err: any) { + logger.warn(`Error reading file ${filePath} in skill ${skillId}: ${err.message}`); + return null; + } + }), + ), + ); + + const files = loadedFiles.filter((file): file is LoadedSkillFile => file !== null); + const skillMdFile = files.find((file) => file.path === 'SKILL.md'); + if (!skillMdFile) { + logger.error(`Skill "${skillId}" is missing SKILL.md`); + return; + } // Determine skill type: "skill-md" if only SKILL.md, "archive" if multiple files - const isArchive = allFiles.length > 1; + const isArchive = files.length > 1; const skillType: SkillType = isArchive ? 'archive' : 'skill-md'; // Compute digest and optional archive let artifactDigest: string; let archiveBase64: string | undefined; + let archiveDigest: string | undefined; if (isArchive) { - // Pre-seed with the SKILL.md we already read to avoid reading it twice - const files: Record = { - 'SKILL.md': { content: skillMdContent, encoding: 'utf-8' }, - }; - const limit = pLimit(10); - - // Track SKILL.md in the watcher map - fileToSkillMap.set(fileURLToPath(skillMdUrl), skillId); - - await Promise.all( - allFiles - .filter((filePath) => normalizeFilePath(filePath) !== 'SKILL.md') - .map((filePath) => - limit(async () => { - const fileUrl = new URL(filePath, skillDirUrl); - const fullPath = fileURLToPath(fileUrl); - const normalizedPath = normalizeFilePath(filePath); - - // Track file -> skill mapping for watcher - fileToSkillMap.set(fullPath, skillId); - - try { - const isBinary = isBinaryFile(filePath); - - let content: string; - let encoding: 'utf-8' | 'base64'; - - if (isBinary) { - const buffer = await fs.readFile(fileUrl); - content = buffer.toString('base64'); - encoding = 'base64'; - } else { - content = await fs.readFile(fileUrl, 'utf-8'); - encoding = 'utf-8'; - } - - files[normalizedPath] = { - content, - encoding, - }; - } catch (err: any) { - logger.warn(`Error reading file ${filePath} in skill ${skillId}: ${err.message}`); - } - }), - ), + const archiveFiles: Record = Object.fromEntries( + files.map((file) => [ + file.path, + { + content: file.content, + encoding: file.encoding, + }, + ]), ); // Generate tar.gz and compute digest of the archive - const archiveBuffer = await generateTarGz(files); - artifactDigest = sha256(archiveBuffer); + const archiveBuffer = await generateTarGz(archiveFiles); + archiveDigest = sha256(archiveBuffer); + artifactDigest = archiveDigest; archiveBase64 = archiveBuffer.toString('base64'); } else { // For skill-md, only need the SKILL.md we already read - artifactDigest = sha256(skillMdRawBuffer); - - // Still track file -> skill mapping for watcher - const skillMdFullPath = fileURLToPath(skillMdUrl); - fileToSkillMap.set(skillMdFullPath, skillId); + artifactDigest = skillMdFile.digest; } // Use the artifact digest for change detection. @@ -297,7 +382,11 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { // - For skill-md: SHA-256 of the SKILL.md raw bytes // - For archive: SHA-256 of the tar.gz (derived from all files) const existingEntry = store.get(skillId); - if (existingEntry && existingEntry.digest === artifactDigest) { + if ( + existingEntry && + existingEntry.digest === artifactDigest && + hasCurrentSkillDataShape(existingEntry.data, skillType) + ) { return; } @@ -312,8 +401,19 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { description: frontmatter.description, type: skillType, digest: artifactDigest, + skillMdDigest: skillMdFile.digest, skillMdRaw: skillMdContent, + frontmatter: skillFrontmatter, + files: files.map(({ path, content, encoding, mimeType, digest, size }) => ({ + path, + content, + encoding, + mimeType, + digest, + size, + })), archive: archiveBase64, + archiveDigest, }, }); @@ -348,21 +448,23 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { watcher.add(basePath); const matchesSkillFile = picomatch('**/SKILL.md'); - const matchesSkillDir = (filePath: string): string | null => { - const rel = posixRelative(basePath, filePath); + const findContainingSkill = (filePath: string): string | null => { + const rel = normalizeFilePath(posixRelative(basePath, filePath)); if (rel.startsWith('..')) return null; - // Check if this file is within a skill directory + const mappedSkillId = fileToSkillMap.get(filePath); + if (mappedSkillId) return mappedSkillId; + const parts = rel.split('/'); - if (parts.length >= 2) { - // Could be in a skill subdirectory - const potentialSkillDir = parts[0]; + for (let index = parts.length - 1; index > 0; index--) { + const potentialSkillDir = parts.slice(0, index).join('/'); const skillMdPath = `${potentialSkillDir}/SKILL.md`; const skillMdFullPath = fileURLToPath(new URL(skillMdPath, baseDir)); if (existsSync(skillMdFullPath)) { return potentialSkillDir; } } + return null; }; @@ -372,7 +474,7 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { // Check if a SKILL.md file changed if (matchesSkillFile(entry)) { - const skillId = dirname(entry); + const skillId = normalizeFilePath(dirname(entry)); const oldId = fileToSkillMap.get(changedPath); await loadSkill(entry, oldId); logger.info(`Reloaded skill "${skillId}"`); @@ -380,7 +482,7 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { } // Check if any file in a skill directory changed - const skillId = matchesSkillDir(changedPath); + const skillId = findContainingSkill(changedPath); if (skillId) { const skillMdPath = `${skillId}/SKILL.md`; await loadSkill(skillMdPath); @@ -397,7 +499,7 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { // If SKILL.md was deleted, remove the skill if (matchesSkillFile(entry)) { - const skillId = dirname(entry); + const skillId = normalizeFilePath(dirname(entry)); store.delete(skillId); fileToSkillMap.delete(deletedPath); logger.info(`Removed skill "${skillId}" (SKILL.md deleted)`); @@ -405,7 +507,7 @@ export function skillsLoader(options: SkillsLoaderOptions = {}): Loader { } // If another file was deleted, reload the skill - const skillId = matchesSkillDir(deletedPath); + const skillId = findContainingSkill(deletedPath); if (skillId) { const skillMdPath = `${skillId}/SKILL.md`; const skillMdFullPath = fileURLToPath(new URL(skillMdPath, baseDir)); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..5f1227b --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,232 @@ +import type { + ResolvedSkillsMcpOptions, + Skill, + SkillData, + SkillsMcpIndex, + SkillsMcpIndexEntry, + SkillsMcpOptions, + SkillsMcpTree, + SkillsMcpTreeDirectoryEntry, + SkillsMcpTreeFileEntry, +} from './types.js'; + +const DEFAULT_MCP_PREFIX = '/.well-known/mcp/skills'; +const DEFAULT_RESOURCE_BASE = 'skill://'; +const ARCHIVE_MIME_TYPE = 'application/gzip'; + +export interface SkillsMcpFileArtifact { + kind: 'file'; + publicPath: string; + content: string; + encoding: 'utf-8' | 'base64'; + mimeType: string; + digest: string; + size: number; +} + +export interface SkillsMcpArchiveArtifact { + kind: 'archive'; + publicPath: string; + content: string; + encoding: 'base64'; + mimeType: typeof ARCHIVE_MIME_TYPE; + digest: string; +} + +export interface SkillsMcpPublication { + index: SkillsMcpIndex; + tree: SkillsMcpTree; + files: SkillsMcpFileArtifact[]; + archives: SkillsMcpArchiveArtifact[]; +} + +type SkillLike = Pick & { + data: SkillData; +}; + +export function resolveSkillsMcpOptions( + options: boolean | SkillsMcpOptions | undefined, +): ResolvedSkillsMcpOptions | null { + if (!options) return null; + + const mcpOptions = options === true ? {} : options; + return { + prefix: normalizeRoutePrefix(mcpOptions.prefix ?? DEFAULT_MCP_PREFIX), + resourceBase: mcpOptions.resourceBase ?? DEFAULT_RESOURCE_BASE, + directoryManifest: mcpOptions.directoryManifest ?? true, + archives: mcpOptions.archives ?? true, + }; +} + +export function createSkillsMcpPublication( + skills: SkillLike[], + options: Pick, +): SkillsMcpPublication { + const sortedSkills = [...skills].sort((a, b) => a.id.localeCompare(b.id)); + const files = sortedSkills.flatMap((skill) => + skill.data.files.map((file): SkillsMcpFileArtifact => ({ + kind: 'file', + publicPath: `${skill.id}/${file.path}`, + content: file.content, + encoding: file.encoding, + mimeType: file.mimeType, + digest: file.digest, + size: file.size, + })), + ); + const archives = options.archives + ? sortedSkills.flatMap((skill): SkillsMcpArchiveArtifact[] => { + if (!skill.data.archive || !skill.data.archiveDigest) return []; + + return [ + { + kind: 'archive', + publicPath: `${skill.id}.tar.gz`, + content: skill.data.archive, + encoding: 'base64', + mimeType: ARCHIVE_MIME_TYPE, + digest: skill.data.archiveDigest, + }, + ]; + }) + : []; + + return { + index: createIndex(sortedSkills, archives, options.resourceBase), + tree: createTree(sortedSkills, files, options.resourceBase), + files, + archives, + }; +} + +function createIndex( + skills: SkillLike[], + archives: SkillsMcpArchiveArtifact[], + resourceBase: string, +): SkillsMcpIndex { + const archiveByPublicPath = new Map( + archives.map((archive) => [archive.publicPath, archive] as const), + ); + const entries: SkillsMcpIndexEntry[] = skills.map((skill) => { + const entry: SkillsMcpIndexEntry = { + frontmatter: skill.data.frontmatter, + url: joinResourceUri(resourceBase, `${skill.id}/SKILL.md`), + digest: skill.data.skillMdDigest, + }; + const archive = archiveByPublicPath.get(`${skill.id}.tar.gz`); + + if (archive) { + entry.archives = [ + { + url: joinResourceUri(resourceBase, archive.publicPath), + mimeType: ARCHIVE_MIME_TYPE, + digest: archive.digest, + }, + ]; + } + + return entry; + }); + + return { skills: entries }; +} + +function createTree( + skills: SkillLike[], + files: SkillsMcpFileArtifact[], + resourceBase: string, +): SkillsMcpTree { + const skillById = new Map(skills.map((skill) => [skill.id, skill] as const)); + const directoryEntries = createDirectoryEntries(files, resourceBase); + const fileEntries = files.map((file) => createTreeFileEntry(file, skillById, resourceBase)); + + return { + entries: [...directoryEntries, ...fileEntries].sort((a, b) => a.path.localeCompare(b.path)), + }; +} + +function createDirectoryEntries( + files: SkillsMcpFileArtifact[], + resourceBase: string, +): SkillsMcpTreeDirectoryEntry[] { + const directories = new Set(); + + for (const file of files) { + const parts = file.publicPath.split('/'); + for (let index = 1; index < parts.length; index++) { + directories.add(parts.slice(0, index).join('/')); + } + } + + return [...directories] + .sort((a, b) => a.localeCompare(b)) + .map((path) => ({ + type: 'directory', + name: lastPathSegment(path), + path, + uri: joinResourceUri(resourceBase, path), + mimeType: 'inode/directory', + })); +} + +function createTreeFileEntry( + file: SkillsMcpFileArtifact, + skillById: Map, + resourceBase: string, +): SkillsMcpTreeFileEntry { + const skill = findOwningSkill(file.publicPath, skillById); + const isSkillMd = skill && file.publicPath === `${skill.id}/SKILL.md`; + + return { + type: 'file', + name: isSkillMd ? skill.data.frontmatter.name : lastPathSegment(file.publicPath), + path: file.publicPath, + uri: joinResourceUri(resourceBase, file.publicPath), + mimeType: file.mimeType, + digest: file.digest, + size: file.size, + ...(isSkillMd + ? { + description: skill.data.frontmatter.description, + _meta: { + 'io.modelcontextprotocol.skills/frontmatter': skill.data.frontmatter, + }, + } + : {}), + }; +} + +function findOwningSkill( + publicPath: string, + skillById: Map, +): SkillLike | undefined { + const matchingSkillIds = [...skillById.keys()] + .filter((skillId) => publicPath === skillId || publicPath.startsWith(`${skillId}/`)) + .sort((a, b) => b.length - a.length); + + return matchingSkillIds.length > 0 ? skillById.get(matchingSkillIds[0]) : undefined; +} + +function joinResourceUri(resourceBase: string, path: string): string { + const base = resourceBase.trim() || DEFAULT_RESOURCE_BASE; + const encodedPath = path.split('/').map(encodeURIComponent).join('/'); + + if (base.endsWith('://') || base.endsWith('/')) { + return `${base}${encodedPath}`; + } + + return `${base}/${encodedPath}`; +} + +function normalizeRoutePrefix(prefix: string): string { + const trimmed = prefix.trim(); + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, ''); + + return withoutTrailingSlash || DEFAULT_MCP_PREFIX; +} + +function lastPathSegment(path: string): string { + const segments = path.split('/'); + return segments[segments.length - 1] ?? path; +} diff --git a/src/routes/agent-skill-resource.ts b/src/routes/agent-skill-resource.ts new file mode 100644 index 0000000..c68c608 --- /dev/null +++ b/src/routes/agent-skill-resource.ts @@ -0,0 +1,102 @@ +import type { APIRoute, GetStaticPaths } from 'astro'; +import type { SkillData } from '../types.js'; + +type FileResourceProps = { + body: string; + contentType: 'text/markdown; charset=utf-8'; + encoding: 'utf-8'; + kind: 'file'; +}; + +type ArchiveResourceProps = { + body: string; + contentType: 'application/gzip'; + encoding: 'base64'; + kind: 'archive'; +}; + +type Props = FileResourceProps | ArchiveResourceProps; + +const cacheHeaders = { + 'Cache-Control': 'public, max-age=3600', +}; + +/** + * Generate static paths for legacy Agent Skills Discovery artifacts. + */ +export const getStaticPaths: GetStaticPaths = async () => { + // Dynamic import of virtual module - resolved at runtime by Astro + // @ts-expect-error - astro:content is a virtual module only available at runtime + const { getCollection } = await import('astro:content'); + const skills = (await getCollection('skills')) as Array<{ id: string; data: SkillData }>; + const paths: Array<{ params: { path: string }; props: Props }> = []; + + for (const skill of skills) { + if (skill.data.type === 'archive' && skill.data.archive) { + paths.push({ + params: { path: `${skill.id}.tar.gz` }, + props: { + body: skill.data.archive, + contentType: 'application/gzip', + encoding: 'base64', + kind: 'archive', + }, + }); + continue; + } + + paths.push({ + params: { path: `${skill.id}/SKILL.md` }, + props: { + body: skill.data.skillMdRaw, + contentType: 'text/markdown; charset=utf-8', + encoding: 'utf-8', + kind: 'file', + }, + }); + } + + return paths; +}; + +/** + * GET /.well-known/agent-skills/[...path] + * + * Serves legacy Agent Skills Discovery artifacts. + */ +export const GET: APIRoute = async ({ props }) => { + const body = props.encoding === 'base64' ? decodeBase64(props.body) : props.body; + + return new Response(body, { + status: 200, + headers: resourceHeaders(props), + }); +}; + +export const HEAD: APIRoute = async ({ props }) => { + return new Response(null, { + status: 200, + headers: resourceHeaders(props), + }); +}; + +function decodeBase64(body: string): ArrayBuffer { + const buffer = Buffer.from(body, 'base64'); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; +} + +function resourceHeaders(props: Props): HeadersInit { + return { + ...cacheHeaders, + 'Content-Type': props.contentType, + 'Content-Length': getContentLength(props).toString(), + }; +} + +function getContentLength(props: Props): number { + if (props.encoding === 'base64') { + return Buffer.from(props.body, 'base64').byteLength; + } + + return Buffer.byteLength(props.body, 'utf-8'); +} diff --git a/src/routes/index-json.ts b/src/routes/index-json.ts index 8417aeb..f8b4f39 100644 --- a/src/routes/index-json.ts +++ b/src/routes/index-json.ts @@ -40,9 +40,20 @@ export const GET: APIRoute = async () => { return new Response(JSON.stringify(index, null, 2), { status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=3600', - }, + headers: jsonHeaders(), }); }; + +export const HEAD: APIRoute = async () => { + return new Response(null, { + status: 200, + headers: jsonHeaders(), + }); +}; + +function jsonHeaders(): HeadersInit { + return { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }; +} diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts new file mode 100644 index 0000000..adae912 --- /dev/null +++ b/src/routes/mcp.ts @@ -0,0 +1,142 @@ +import type { APIRoute, GetStaticPaths } from 'astro'; +import { createSkillsMcpPublication } from '../mcp.js'; +import type { ResolvedSkillsMcpOptions, SkillData } from '../types.js'; +import { isTextMimeType } from '../utils.js'; + +// Injected by the integration's Vite plugin. +// @ts-expect-error - virtual module resolved by astro-skills at runtime +import mcpConfig from 'astro-skills:mcp-config'; + +type JsonArtifactProps = { + body: string; + contentType: 'application/json; charset=utf-8'; + kind: 'json'; +}; + +type FileArtifactProps = { + body: string; + contentType: string; + encoding: 'utf-8' | 'base64'; + kind: 'file'; +}; + +type Props = JsonArtifactProps | FileArtifactProps; + +const config = mcpConfig as ResolvedSkillsMcpOptions; +const cacheHeaders = { + 'Cache-Control': 'public, max-age=3600', +}; + +/** + * Generate static paths for the experimental MCP/SEP skill publication. + */ +export const getStaticPaths: GetStaticPaths = async () => { + // Dynamic import of virtual module - resolved at runtime by Astro + // @ts-expect-error - astro:content is a virtual module only available at runtime + const { getCollection } = await import('astro:content'); + const skills = (await getCollection('skills')) as Array<{ id: string; data: SkillData }>; + const publication = createSkillsMcpPublication(skills, config); + + return [ + { + params: { path: 'index.json' }, + props: jsonProps(publication.index), + }, + ...(config.directoryManifest + ? [ + { + params: { path: '.tree.json' }, + props: jsonProps(publication.tree), + }, + ] + : []), + ...publication.files.map((file) => ({ + params: { path: file.publicPath }, + props: { + body: file.content, + contentType: contentTypeHeader(file.mimeType), + encoding: file.encoding, + kind: 'file', + } satisfies FileArtifactProps, + })), + ...publication.archives.map((archive) => ({ + params: { path: archive.publicPath }, + props: { + body: archive.content, + contentType: archive.mimeType, + encoding: archive.encoding, + kind: 'file', + } satisfies FileArtifactProps, + })), + ]; +}; + +/** + * GET /.well-known/mcp/skills/[...path] + * + * Serves experimental SEP-2640/MCP skill artifacts. + */ +export const GET: APIRoute = async ({ props }) => { + if (props.kind === 'json') { + return new Response(props.body, { + status: 200, + headers: artifactHeaders(props), + }); + } + + return new Response(decodeBody(props.body, props.encoding), { + status: 200, + headers: artifactHeaders(props), + }); +}; + +export const HEAD: APIRoute = async ({ props }) => { + return new Response(null, { + status: 200, + headers: artifactHeaders(props), + }); +}; + +function jsonProps(body: unknown): JsonArtifactProps { + return { + body: `${JSON.stringify(body, null, 2)}\n`, + contentType: 'application/json; charset=utf-8', + kind: 'json', + }; +} + +function contentTypeHeader(mimeType: string): string { + return isTextMimeType(mimeType) ? `${mimeType}; charset=utf-8` : mimeType; +} + +function decodeBody(body: string, encoding: 'utf-8' | 'base64'): string | ArrayBuffer { + if (encoding === 'utf-8') return body; + + if (typeof Buffer !== 'undefined') { + const buffer = Buffer.from(body, 'base64'); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; + } + + const binary = atob(body); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +} + +function artifactHeaders(props: Props): HeadersInit { + return { + ...cacheHeaders, + 'Content-Type': props.contentType, + 'Content-Length': getContentLength(props).toString(), + }; +} + +function getContentLength(props: Props): number { + if (props.kind === 'file' && props.encoding === 'base64') { + return Buffer.from(props.body, 'base64').byteLength; + } + + return Buffer.byteLength(props.body, 'utf-8'); +} diff --git a/src/schema.ts b/src/schema.ts index 333bfae..ccf1cdc 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -7,15 +7,37 @@ export const skillSchema = z.object({ /** Skill name from SKILL.md frontmatter */ name: z.string().min(1).max(64), /** Skill description from SKILL.md frontmatter */ - description: z.string().max(1024), + description: z.string().min(1).max(1024), /** Distribution type: "skill-md" for single SKILL.md, "archive" for bundled archive */ type: z.enum(['skill-md', 'archive']), /** SHA-256 content digest of the artifact, formatted as sha256:{hex} */ digest: z.string(), + /** SHA-256 content digest of the raw SKILL.md bytes, formatted as sha256:{hex} */ + skillMdDigest: z.string(), /** Raw SKILL.md content (UTF-8 string) - used for serving SKILL.md directly */ skillMdRaw: z.string(), + /** Full SKILL.md frontmatter */ + frontmatter: z + .object({ + name: z.string().min(1).max(64), + description: z.string().min(1).max(1024), + }) + .passthrough(), + /** Files in this skill directory */ + files: z.array( + z.object({ + path: z.string(), + content: z.string(), + encoding: z.enum(['utf-8', 'base64']), + mimeType: z.string(), + digest: z.string(), + size: z.number().int().nonnegative(), + }), + ), /** Pre-generated tar.gz archive (base64-encoded) - only present for archive type skills */ archive: z.string().optional(), + /** SHA-256 content digest of the archive bytes, formatted as sha256:{hex} */ + archiveDigest: z.string().optional(), }); export type SkillSchema = z.infer; diff --git a/src/static-headers.ts b/src/static-headers.ts new file mode 100644 index 0000000..73fde40 --- /dev/null +++ b/src/static-headers.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ResolvedSkillsMcpOptions } from './types.js'; +import { getMimeType, isTextMimeType, normalizeFilePath } from './utils.js'; + +const MANAGED_BLOCK_START = '# astro-skills:start'; +const MANAGED_BLOCK_END = '# astro-skills:end'; +const CACHE_CONTROL = 'public, max-age=3600'; + +interface StaticHeadersOptions { + mcp: ResolvedSkillsMcpOptions | null; +} + +interface HeaderEntry { + path: string; + headers: Array<[string, string]>; +} + +export async function writeStaticHeaders( + dir: URL, + options: StaticHeadersOptions, +): Promise { + const outDir = fileURLToPath(dir); + const roots = [ + { + prefix: '/.well-known/agent-skills', + directory: join(outDir, '.well-known', 'agent-skills'), + }, + ]; + + if (options.mcp) { + roots.push({ + prefix: options.mcp.prefix, + directory: join(outDir, ...options.mcp.prefix.split('/').filter(Boolean)), + }); + } + + const entries: HeaderEntry[] = []; + for (const root of roots) { + const files = await listFiles(root.directory); + for (const file of files) { + const relativePath = normalizeFilePath(relative(root.directory, file)); + entries.push(createHeaderEntry(`${root.prefix}/${relativePath}`, relativePath)); + } + } + + if (entries.length === 0) return 0; + + const headersPath = join(outDir, '_headers'); + const existing = await fs.readFile(headersPath, 'utf-8').catch(() => ''); + const next = mergeManagedBlock(existing, renderHeaders(entries)); + await fs.writeFile(headersPath, next); + + return entries.length; +} + +async function listFiles(directory: string): Promise { + const entries = await fs.readdir(directory, { recursive: true, withFileTypes: true }).catch(() => []); + + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join('parentPath' in entry ? entry.parentPath : directory, entry.name)) + .sort((a, b) => a.localeCompare(b)); +} + +function createHeaderEntry(path: string, relativePath: string): HeaderEntry { + const contentType = getContentType(relativePath); + const headers: Array<[string, string]> = [ + ['Content-Type', contentType], + ['Cache-Control', CACHE_CONTROL], + ]; + + if (relativePath.endsWith('.tar.gz')) { + headers.push(['Content-Encoding', 'identity']); + } + + return { path, headers }; +} + +function getContentType(path: string): string { + const mimeType = getMimeType(path); + return isTextMimeType(mimeType) ? `${mimeType}; charset=utf-8` : mimeType; +} + +function renderHeaders(entries: HeaderEntry[]): string { + return [ + MANAGED_BLOCK_START, + '# Generated by astro-skills for static hosts that read _headers files.', + ...entries.flatMap((entry) => [ + entry.path, + ...entry.headers.map(([name, value]) => ` ${name}: ${value}`), + '', + ]), + MANAGED_BLOCK_END, + ].join('\n'); +} + +function mergeManagedBlock(existing: string, block: string): string { + const normalizedBlock = `${block.trimEnd()}\n`; + const start = existing.indexOf(MANAGED_BLOCK_START); + const end = existing.indexOf(MANAGED_BLOCK_END); + + if (start !== -1 && end !== -1 && end > start) { + const before = existing.slice(0, start).trimEnd(); + const after = existing.slice(end + MANAGED_BLOCK_END.length).trimStart(); + return [before, normalizedBlock, after].filter(Boolean).join('\n\n'); + } + + if (!existing.trim()) { + return normalizedBlock; + } + + return `${existing.trimEnd()}\n\n${normalizedBlock}`; +} diff --git a/src/types.ts b/src/types.ts index e8cb6e1..1d53ecb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,10 +5,54 @@ export const SCHEMA_URI = 'https://schemas.agentskills.io/discovery/0.2.0/schema /** * Options for the skills integration - * Currently reserved for future use. */ export interface SkillsIntegrationOptions { - // Reserved for future options + /** + * Experimental SEP-2640/MCP Skills output mode. + * + * Set to `true` to enable the default `/.well-known/mcp/skills` routes, or + * pass an object to customize the generated route and resource URIs. + * + * @default false + */ + mcp?: boolean | SkillsMcpOptions; +} + +/** + * Options for experimental SEP-2640/MCP Skills output. + */ +export interface SkillsMcpOptions { + /** + * Static route prefix for generated MCP skill artifacts. + * @default '/.well-known/mcp/skills' + */ + prefix?: string; + /** + * Resource URI base used in generated index and tree manifests. + * @default 'skill://' + */ + resourceBase?: string; + /** + * Generate a `.tree.json` directory manifest. + * @default true + */ + directoryManifest?: boolean; + /** + * Include generated tar.gz archive resources in the MCP index. + * Archives are generated for multi-file skills. + * @default true + */ + archives?: boolean; +} + +/** + * Fully resolved MCP output options. + */ +export interface ResolvedSkillsMcpOptions { + prefix: string; + resourceBase: string; + directoryManifest: boolean; + archives: boolean; } /** @@ -27,6 +71,32 @@ export interface SkillsLoaderOptions { */ export type SkillType = 'skill-md' | 'archive'; +/** + * Full SKILL.md YAML frontmatter as rendered to JSON. + */ +export type SkillFrontmatter = Record & { + name: string; + description: string; +}; + +/** + * Represents a file within a skill directory. + */ +export interface SkillFileData { + /** Path relative to the skill directory root */ + path: string; + /** File content (UTF-8 string or base64-encoded for non-text files) */ + content: string; + /** Encoding used for the content */ + encoding: 'utf-8' | 'base64'; + /** MIME type for serving the file as a resource */ + mimeType: string; + /** SHA-256 content digest of the raw file bytes, formatted as sha256:{hex} */ + digest: string; + /** Raw file size in bytes */ + size: number; +} + /** * Represents a skill's data as stored in the content collection */ @@ -39,10 +109,18 @@ export interface SkillData { type: SkillType; /** SHA-256 content digest of the artifact, formatted as sha256:{hex} */ digest: string; + /** SHA-256 content digest of the raw SKILL.md bytes, formatted as sha256:{hex} */ + skillMdDigest: string; /** Raw SKILL.md content (UTF-8 string) */ skillMdRaw: string; + /** Full SKILL.md frontmatter */ + frontmatter: SkillFrontmatter; + /** Files in this skill directory */ + files: SkillFileData[]; /** Pre-generated tar.gz archive (base64-encoded) - only present for archive type skills */ archive?: string; + /** SHA-256 content digest of the archive bytes, formatted as sha256:{hex} */ + archiveDigest?: string; } /** @@ -75,3 +153,66 @@ export interface SkillsIndex { $schema: string; skills: SkillsIndexEntry[]; } + +/** + * Archive entry in the experimental SEP-2640/MCP skill index. + */ +export interface SkillsMcpArchiveEntry { + url: string; + mimeType: 'application/gzip'; + digest: string; +} + +/** + * Skill entry in the experimental SEP-2640/MCP skill index. + */ +export interface SkillsMcpIndexEntry { + frontmatter: SkillFrontmatter; + url?: string; + digest?: string; + archives?: SkillsMcpArchiveEntry[]; +} + +/** + * Experimental SEP-2640/MCP skill index. + */ +export interface SkillsMcpIndex { + skills: SkillsMcpIndexEntry[]; +} + +/** + * Directory entry in the experimental MCP tree manifest. + */ +export interface SkillsMcpTreeDirectoryEntry { + type: 'directory'; + name: string; + path: string; + uri: string; + mimeType: 'inode/directory'; +} + +/** + * File entry in the experimental MCP tree manifest. + */ +export interface SkillsMcpTreeFileEntry { + type: 'file'; + name: string; + path: string; + uri: string; + mimeType: string; + digest: string; + size: number; + description?: string; + _meta?: { + 'io.modelcontextprotocol.skills/frontmatter': SkillFrontmatter; + }; +} + +export type SkillsMcpTreeEntry = SkillsMcpTreeDirectoryEntry | SkillsMcpTreeFileEntry; + +/** + * Experimental MCP tree manifest for directory-read adapters. + */ +export interface SkillsMcpTree { + entries: SkillsMcpTreeEntry[]; +} diff --git a/src/utils.ts b/src/utils.ts index 0423e01..7f32588 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,52 +57,142 @@ export function getSkillNameValidationError(name: string): string | null { } /** - * Binary file extensions that should be base64-encoded in archives + * Validates a slash-separated skill path for SEP-2640 resource mapping. + * + * Prefix segments are server-chosen organization. The final segment is the + * skill name and must satisfy the Agent Skills naming rules. */ -const BINARY_EXTENSIONS = new Set([ - // Images - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.ico', - '.bmp', - '.tiff', - '.tif', +export function isValidSkillPath(path: string): boolean { + return getSkillPathValidationError(path) === null; +} - // Documents - '.pdf', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', +/** + * Returns validation error message for an invalid skill path, or null if valid. + */ +export function getSkillPathValidationError(path: string): string | null { + if (!path) { + return 'Skill path cannot be empty'; + } - // Archives - '.zip', - '.tar', - '.gz', - '.rar', - '.7z', - '.bz2', + const normalized = normalizeFilePath(path); + const segments = normalized.split('/'); + if (segments.some((segment) => segment.length === 0)) { + return 'Skill path cannot contain empty segments'; + } + if (segments.some((segment) => segment === '.' || segment === '..')) { + return 'Skill path cannot contain "." or ".." segments'; + } + + const skillName = segments.at(-1); + if (!skillName) { + return 'Skill path cannot be empty'; + } + + const skillNameError = getSkillNameValidationError(skillName); + if (skillNameError) { + return `Final path segment is invalid: ${skillNameError}`; + } + + return null; +} - // Other binary - '.wasm', - '.exe', - '.dll', - '.so', - '.dylib', - '.bin', +/** + * File extensions that can be safely stored as UTF-8 strings. Unknown + * extensions default to base64 so served resource bytes still match digests. + */ +const TEXT_EXTENSIONS = new Set([ + '.css', + '.csv', + '.html', + '.js', + '.json', + '.md', + '.mdc', + '.mjs', + '.py', + '.sh', + '.svg', + '.toml', + '.ts', + '.tsx', + '.txt', + '.xml', + '.yaml', + '.yml', ]); /** - * Determines if a file should be treated as binary (and base64-encoded). + * Determines if a file should be stored as UTF-8 text. */ -export function isBinaryFile(filePath: string): boolean { +export function isTextFile(filePath: string): boolean { const ext = extname(filePath).toLowerCase(); - return BINARY_EXTENSIONS.has(ext); + return TEXT_EXTENSIONS.has(ext); +} + +/** + * MIME types used when serving skill files as resources. + */ +export function getMimeType(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + switch (ext) { + case '.md': + case '.mdc': + return 'text/markdown'; + case '.json': + return 'application/json'; + case '.js': + case '.mjs': + return 'application/javascript'; + case '.ts': + case '.tsx': + return 'text/typescript'; + case '.py': + return 'text/x-python'; + case '.sh': + return 'text/x-shellscript'; + case '.svg': + return 'image/svg+xml'; + case '.html': + return 'text/html'; + case '.css': + return 'text/css'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.pdf': + return 'application/pdf'; + case '.zip': + return 'application/zip'; + case '.gz': + return 'application/gzip'; + case '.tar': + return 'application/x-tar'; + case '.txt': + case '.yaml': + case '.yml': + case '.toml': + return 'text/plain'; + default: + return 'application/octet-stream'; + } +} + +/** + * Determines if a MIME type can be safely served as UTF-8 text. + */ +export function isTextMimeType(mimeType: string): boolean { + return ( + mimeType.startsWith('text/') || + mimeType === 'application/json' || + mimeType === 'application/javascript' || + mimeType === 'image/svg+xml' + ); } /**