Skip to content

Architecture

Android Skills MCP is a pnpm workspace with three packages. The MCP server and the packager CLI each have a thin surface, and both share a common parser plus a search index that lives in @android-skills/core.

Repository layout

android-mcp/
├── packages/
│   ├── core/   @android-skills/core   shared parser, schema, search index
│   ├── mcp/    android-skills-mcp     MCP server (stdio)
│   └── pack/   android-skills-pack    packager CLI
├── scripts/    sync-skills.mjs, build-skills-index.mjs
└── skills/     upstream android/skills clone (gitignored)

The shared core is intentionally small. It does three things: parse a SKILL.md, validate it against the agentskills.io schema, and build a BM25 index over the parsed result.

How parsing works

The parser reads a SKILL.md with gray-matter, which splits the YAML frontmatter from the markdown body. The frontmatter is then validated against a zod schema that enforces the agentskills.io spec:

SkillFrontmatterSchema = z.object({
  name: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).max(64),
  description: z.string().min(1).max(1024),
  license: z.string().optional(),
  compatibility: z.string().max(500).optional(),
  'allowed-tools': z.string().optional(),
  metadata: z.object({
    author: z.string().optional(),
    version: z.string().optional(),
    keywords: z.array(z.string()).default([]),
  }).passthrough(),
}).passthrough();

The body is scanned for ^#{1,6} headings and for relative markdown links pointing at references/. Both are extracted into typed arrays. The parser does not load reference contents at this stage; it only collects paths. References are loaded lazily by loadSkillReferences() when needed.

How the search index works

buildIndex(skills) wraps MiniSearch with a fixed configuration:

Field Boost Source
name 5 Skill name
keywords 3 metadata.keywords joined with spaces
description 2 Frontmatter description
headings 1 All ^#{1,6} headings from the body

BM25 ranks results. Prefix matching and 20% fuzzy matching are enabled, so partial words and minor typos still match. The index is built once at server startup and held in memory. With six skills and a few hundred terms, the build takes around 10 ms.

The choice of BM25 over embedding based search is deliberate. The catalog is small, the keywords are curated, and the queries are short. BM25 produces better top hits in this regime than dense embeddings, with no model download, no warm up, and no native deps. If the catalog grows past a few hundred skills or if telemetry shows that BM25 misses real queries, embeddings would be a justified upgrade.

How the MCP server bundles skills

When you pnpm build the MCP package, two things happen. First, tsup bundles the TypeScript sources into dist/index.js and dist/bin.js. Second, the build script walks the cloned skills/ directory, parses every SKILL.md via @android-skills/core, inlines every reference content, strips absolute paths, and writes the result to dist/skills.json.

The published npm package ships dist/skills.json alongside the bundled JS. At server startup, the bundle is loaded once, parsed in around 5 ms, and held in memory. There are zero runtime file system reads after startup, and zero network calls.

If you pass --skills-dir <path>, the server bypasses the bundle and parses fresh from the directory you point at. This is useful when you fork the upstream catalog or want changes that haven't shipped in a release yet.

How the packager produces target outputs

The packager's Target interface is small:

interface Target {
  id: string;
  description: string;
  defaultOutDir(cwd: string): string;
  render(skills: Skill[], ctx?: RenderCtx): RenderedFile[];
}

Each of the seven target adapters implements render. Some return one file per skill (cursor, copilot, continue, claude-code, junie). Others return a single file (gemini, aider). The executeInstall function takes a target's output, resolves paths against an output directory, and writes the files (with idempotency and --force support).

YAML emission goes through a small frontmatter() helper in render.ts. It quotes string values when they would otherwise be misparsed: alias prefixes (*foo), embedded colons, reserved keywords (true, null), and globs (**). This is what makes applyTo: "**" come out quoted in the copilot target.

Reference handling depends on the target. Claude Code and Junie mirror the layout (one directory per skill with a references/ sibling). The other targets inline references at the end of the main file under ## references/<path> headings.

Testing

The test pyramid:

Layer Tests What it covers
@android-skills/core 15 Parser against all 6 real skills, schema invariants, search ranking
android-skills-mcp 9 In-memory MCP transport: list_skills, search_skills, get_skill, resources
android-skills-pack 40 Per-target snapshot tests (paths, sizes, head 200 chars), install lifecycle

Total 64 tests, all running in around 1.5 seconds. The tests parse the actual upstream skills/ directory rather than fixtures, so any upstream change that breaks the parser surfaces immediately.

The MCP server tests use the InMemoryTransport.createLinkedPair() helper to wire a server and a client in the same process, so the assertions go through the real MCP protocol stack without spawning subprocesses.