Skip to main content
Glama
olaservo

skills-over-mcp-demo

by olaservo
README.md
# skills-over-mcp-demo

Demo MCP server that serves [Agent Skills](https://agentskills.io/) over MCP per [SEP-2640 v1 (Skills Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), built on the v2 TypeScript SDK (`@modelcontextprotocol/server`) and the experimental skills extension SDK ([`@olaservo/ext-skills`](https://www.npmjs.com/package/@olaservo/ext-skills)).

## Quickstart

```bash
npm install
npm run smoke       # build + end-to-end client checks over stdio
npm run smoke:http  # same checks over streamable HTTP (the transport OpenAI's importer uses)
```

Both smoke tests connect with `versionNegotiation: { mode: "auto" }` and assert the connection lands on the latest protocol revision (2026-07-28) and that `skills/list` carries the SEP-2549 caching attributes, which the SEP scopes to that revision.

Or run the server directly for use from a host:

```bash
npm run build
node dist/index.js                    # stdio, serves the bundled skills/ directory
node dist/index.js path/to/dir        # stdio, different skills directory
node dist/http.js --port 3000        # streamable HTTP at http://127.0.0.1:3000/mcp
```

Claude Desktop / generic stdio host config:

```json
{
  "mcpServers": {
    "skills-over-mcp-demo": {
      "command": "node",
      "args": ["<absolute path>/dist/index.js"]
    }
  }
}
```

## What it demonstrates

| SEP-2640 surface | Where |
|---|---|
| Capability declaration `capabilities.extensions["io.modelcontextprotocol/skills"]` | `registerSkillResources()` declares it before connect |
| `skills/list` — entries with verbatim frontmatter + complete `{uri, digest}` manifest | 3 listed skills |
| `skills/get` — entry retrieval by URI, listed or not; `-32602` for non-skills | the unlisted `secret-menu` skill |
| Partial listings + server `instructions` as a skill pointer | `secret-menu` is served but never enumerated; instructions point to it |
| `resources/read` — every skill file individually addressable | `skill://…` resources + catch-all template |
| `resources/directory/read` (optional, gated on `directoryRead`) | declared and implemented |
| SEP-2549 list-caching attributes on `skills/list` (2026-07-28+) | `ttlMs: 60_000`, `cacheScope: "public"` |
| Multi-segment skill paths | `skill://dice-roller/tabletop-dice/SKILL.md` |
| Opt-in tools bridge (`--tools-bridge`) | `list_skills` + `read_skill` (read-only, `annotations.readOnlyHint`) |

Interop finding: **ChatGPT connector creation fails against a tools-less MCP server** — the "New Plugin" dialog errors out generically when `tools/list` returns `-32601`, even though the server is a valid SEP-2640 skills/resources server. The `--tools-bridge` flag exists for that case. It is **off by default and not part of SEP-2640**: the SEP defines no server-side tools (its reader-tool guidance is for host-provided tools), so the default surface stays pure-SEP. The flag mirrors that host-side reader tool server-side so tool-only hosts can consume the skills at all; the HF Space deployment enables it.

Skills served:

```
skill://dice-roller/tabletop-dice/SKILL.md    multi-segment path, references/ supporting file
skill://mcp-glossary/SKILL.md                 single-file skill
skill://release-notes-writer/SKILL.md         templates/ supporting file
skill://secret-menu/SKILL.md                  UNLISTED — reachable via skills/get + instructions pointer only
```

The smoke test (`src/smoke.ts`) exercises the client side of the SEP: capability gating, enumeration, unlisted-skill retrieval, digest + frontmatter verification on reads, unlisted-file rejection semantics, directory walking, and `discoverAndBuildCatalog()` producing a system-prompt catalog that merges the instructions-pointer skill with the listing.

## Public endpoint (Hugging Face Space)

The server is deployed as a public Docker Space at [`olaservo/skills-over-mcp-demo`](https://huggingface.co/spaces/olaservo/skills-over-mcp-demo):

```
https://olaservo-skills-over-mcp-demo.hf.space/mcp
```

Deployment lives in `hf-space/`: `assemble.ps1` stages a self-contained bundle (source + skills + Dockerfile + Space README) into `hf-space/.staging`, then `hf upload olaservo/skills-over-mcp-demo hf-space/.staging . --repo-type space` pushes it. Verify the live endpoint with `tsx src/smoke-http.ts https://olaservo-skills-over-mcp-demo.hf.space/mcp`. Free CPU Spaces sleep after inactivity; the first request after a sleep is slow.

Deployment note: the first push got auto-flagged by HF's abuse handler (rule "Cloudflare" — tunnel-domain strings in a source docstring). Removing the `trycloudflare.com` references and recreating the Space cleared it; keep tunnel hostnames out of Space content.

## Testing the OpenAI skills import (Scan Tools)

OpenAI imports skills server-side: during plugin submission, the developer portal's **Scan Tools** connects to your MCP server over streamable HTTP, runs `skills/list` / `skills/get` / `resources/read`, verifies digests, and snapshots the skills into the plugin draft. Codex and ChatGPT then receive skills from OpenAI's plugin backend, not from your server. The test ladder:

1. **Local validation with MCP Inspector** (OpenAI's prescribed pre-submission check):

   ```bash
   npm run start:http                                     # terminal 1
   npx @modelcontextprotocol/inspector                    # terminal 2 → Streamable HTTP → http://127.0.0.1:3000/mcp
   # or non-interactive:
   npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --transport http --method resources/list
   ```

2. **Expose publicly** — the deployed Hugging Face Space above is the stable public endpoint. (Alternative: a quick tunnel — start one against `http://localhost:3000`, then restart the server with `--allow-host <tunnel-hostname>`.)

3. **Run Scan Tools** in the OpenAI developer portal against `https://olaservo-skills-over-mcp-demo.hf.space/mcp` during plugin creation, then check the imported draft.

**Result (2026-08-03): confirmed.** Scan Tools imported all 3 listed skills with "Passed" validation; unlisted `secret-menu` did not import (the importer consumes only `skills/list`). Full findings — including the tools-surface requirement for ChatGPT connectors and the explicit-annotations validation — in [`docs/openai-plugin-submission.md`](docs/openai-plugin-submission.md). Expected: the 3 listed skills import with digests verified; `secret-menu` does not (their importer consumes only `skills/list`). Then exercise the plugin in ChatGPT developer mode.

The demo intentionally fits OpenAI's import limits (≤5 uniquely named skills, ≤100 files/skill, `SKILL.md` ≤256 KiB, files ≤1 MiB).

## OpenAI skills import vs SEP-2640

OpenAI's [MCP server plugin docs](https://developers.openai.com/plugins/build/mcp-server#import-skills-from-the-mcp-server) support importing skills from an MCP server, and their mechanism is recognizably SEP-2640 v1. Checked against the current SEP text (PR #2640, `seps/2640-skills-extension.md`):

**Matches the SEP:**

- Capability declared at `capabilities.extensions["io.modelcontextprotocol/skills"]` — OpenAI's docs even call out that the earlier `experimental` location is wrong.
- `skills/list` (paginated via `nextCursor`) returning `uri`, verbatim `frontmatter` (with required `name` and `description`), and a complete `resources` manifest; `skills/get` for single-entry retrieval; content fetched via `resources/read`.
- Digest format `sha256:` + 64 lowercase hex chars; text resources hashed over the UTF-8 bytes of `content.text`, blob resources hashed over the base64-decoded bytes — exactly the SEP's raw-bytes rule.
- Directory-name-equals-skill-name (the SEP's "final `<skill-path>` segment MUST equal `frontmatter.name`").
- Import-time verification that fetched content matches digests and that `SKILL.md` frontmatter matches the catalog entry exactly — both host-side MUSTs in the SEP.
- Rejecting skills whose resources aren't all fetchable/digestible — the SEP says hosts MAY decline skills without a `resources` manifest, and OpenAI does.

**Differences (host policy or stricter-than-SEP):**

- **`skill://` treated as required.** OpenAI says "use the `skill://` URI convention." The SEP says servers SHOULD use `skill://` but MAY serve skills under any scheme, and hosts MUST NOT infer skill-ness from the scheme. If OpenAI's scanner actually rejects non-`skill://` URIs, that's stricter than the SEP — a conformant `github://…` skills server would fail import. Practical takeaway: stick to `skill://` (as this demo does) for maximum compatibility.
- **Import limits are OpenAI policy, not SEP.** ≤5 uniquely named skills per scan, ≤10 catalog pages, ≤100 files per skill, `SKILL.md` ≤256 KiB, supporting files ≤1 MiB, ≤5 MiB per skill — none of these appear in the SEP. A conformant server can exceed them and simply won't import fully.
- **Archives are OpenAI-internal.** Their "generated archives per scan ≤8 MiB" limit refers to post-import packaging on their side. The SEP explicitly removed archive distribution from the wire protocol (see its Deferred Features appendix); no conflict, but worth knowing the archive never crosses the MCP connection.
- **Snapshot-only, listing-only.** OpenAI imports at scan time and requires re-scanning to pick up changes — consistent with the SEP's point-in-time entries. But an import pipeline only consumes `skills/list`, so SEP features aimed at live hosts don't apply: unlisted skills (like this demo's `secret-menu`), the instructions pointer, and `resources/directory/read` are invisible to OpenAI's importer. Servers targeting OpenAI import should list everything they want imported.

## Layout

```
src/server.ts      shared factory: loadSkills() + registerSkillResources()
src/index.ts       stdio entry point (serveStdio)
src/http.ts        streamable HTTP entry point (createMcpHandler + @modelcontextprotocol/node)
src/checks.ts      shared SEP-2640 client-side checks
src/smoke.ts       stdio smoke test
src/smoke-http.ts  streamable HTTP smoke test
skills/            the demo skills (Agent Skills format: SKILL.md + supporting files)
```