A working e-commerce support MCP server that demonstrates Skills over MCP (SEP-2640) over Streamable HTTP, built for testing in MCPJam.
An MCP server has always been able to give an agent actions, meaning tools it can call. It has had no good way to ship the know-how: the workflow that says which tool to call, in what order, under which conditions, and when to stop and ask the user.
Skills over MCP fixes that. A skill is a folder of Markdown (a SKILL.md
plus any supporting files) that the server publishes as ordinary MCP
resources. The agent discovers what skills a server has (skills/list), gets
one skill's manifest with a SHA-256 digest and byte size for each of its files
(skills/get), and then reads those files individually with the standard
resources/read, pulling in the deep detail only at the moment it needs it.
That last part is progressive disclosure: the agent loads SKILL.md when it
starts handling a refund, and only reads refund-policy.md when it actually has
to check an eligibility rule.
| Tools | Skills | |
|---|---|---|
| What they are | Callable functions | Instructional content |
| What they do | Perform an action | Teach how and when to combine actions |
| Example here | create_refund creates a refund |
handle-refund-request says: never refund without an explicit confirmation, escalate a missing package instead of refunding it |
| Delivered as | tools/list + tools/call |
skills/list + skills/get + resources/read |
The demo makes the split concrete. The four tools happily enforce their own server-side rules but know nothing about conversation flow. The skill supplies the flow.
This implementation follows modelcontextprotocol#2640
at commit a3e147ca2710f68214247aecc729731ee1ae8d03
(PR head as of 2026-08-25), which is the source of truth for the spec.
The synced baseline in
experimental-ext-skills
(docs/sep-draft-skills-extension.md at 9f55cd349932ba00fc18402873c9eb2d2c2e78cb,
a copy of upstream 0eb05fe) is older than the PR head and was not used as the
implementation target. The two differ materially: the PR head adds a required
size field to every resources entry, replaces the "omit resources for
dynamic skills" rule with an explicit "resources": "dynamic" marker, adds the
per-skill limits table (512 entries / 16 MiB), and shows resultType on list
results. This server implements the newer PR-head shape.
The suggested fallback baseline d7490ecd1a250f7bc8c3ebb0d65450dfec274bad was
not used, since a newer revision resolved.
SEP-2640 is still evolving. The SEP document's own header reads
Status: Draft, while the Skills Over MCP WG charter
lists the Skills Extension SEP as In Review, championed by
@pja-ant. This demo follows the version available
at implementation time (2026-08-27). If the spec has moved on, the method names,
field names, or capability shape below may no longer match.
Both commit SHAs above were checked against the GitHub API on 2026-08-27 and
resolve to real commits (a3e147ca dated 2026-08-25, 9f55cd34 dated
2026-08-05), with the linked spec files returning content at each.
- Capability declaration in
initialize:capabilities.extensions["io.modelcontextprotocol/skills"] = { "directoryRead": true } skills/list: paginated (opaque base64url cursor), returns complete skill entries with verbatimfrontmatterand a fullresourcesmanifest.skills/get: returns one skill's entry by URI;-32602for a URI the server does not serve as a skill.resources/directory/read: the optional directory-listing method, gated behind thedirectoryReadsetting.resources/read: serves skill files with correct MIME types.resources/list: resource metadata for each skill file;SKILL.mdtakes itsnameanddescriptionfrom the frontmatter, per the SEP's Resource Metadata section.- URI convention
skill://<skill-name>/<file-path>, with the final skill-path segment equal to thenamein theSKILL.mdfrontmatter. - SHA-256 digests (
sha256:<64 hex>) and bytesizefor every declared file.
There is no fallback path. The skill content is not exposed as a tool or as
a non-skill:// resource. If the extension does not work, the demo fails
visibly.
- Node.js >= 20.11 (developed and tested on Node 24)
- npm
- No database, no auth, no external APIs. All state is in memory and lives only for the lifetime of the process.
npm installnpm run dev # tsx watch, restarts on changenpm run build # tsc -> build/
npm start # node build/src/index.jsPORT=4000 npm start # the port is configurablenpm test # builds, then runs node:test over build/tests/Start the server, then in MCPJam add a server with:
- Transport: Streamable HTTP
- URL:
http://localhost:3001/mcp
That is the whole configuration: no headers, no auth. A health endpoint is at
http://localhost:3001/healthz if you want to confirm the process is up first.
The transport runs stateless: every POST is handled independently and no
Mcp-Session-Id is issued. GET /mcp and DELETE /mcp return 405 by design.
The demo data is process-global, so refunds you create in one Playground turn
are visible in the next.
In MCPJam's Tools tab you should see four tools. Quick checks:
list_orderswith no arguments → 8 orders.get_orderwith{"orderId": "order_1042"}→ damaged, delivered 5 days ago,withinRefundWindow: true.create_refundwith{"orderId": "order_2048", "reason": "damaged"}→ erroroutside_refund_window. Server-side rules hold even when a tool is called directly.escalate_casewith{"orderId": "order_6104", "reason": "unhappy"}→ errorrecord_shows_no_problem. Add"conflictsWithRecord": trueand it succeeds.
In MCPJam's Skills/Resources view, or by curl:
call() { curl -s -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-X POST http://localhost:3001/mcp -d "$1" | tr -d '\r' | sed -n 's/^data: //p'; }
# 1. The extension is declared
call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
# 2. Enumerate skills
call '{"jsonrpc":"2.0","id":2,"method":"skills/list","params":{}}'
# 3. One skill's manifest
call '{"jsonrpc":"2.0","id":3,"method":"skills/get","params":{"uri":"skill://handle-refund-request/SKILL.md"}}'
# 4. Read the two skill files
call '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"skill://handle-refund-request/SKILL.md"}}'
call '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"skill://handle-refund-request/refund-policy.md"}}'
# 5. Directory listing (optional method, declared via directoryRead)
call '{"jsonrpc":"2.0","id":6,"method":"resources/directory/read","params":{"uri":"skill://handle-refund-request"}}'
# 6. Path traversal is rejected with -32602
call '{"jsonrpc":"2.0","id":7,"method":"resources/read","params":{"uri":"skill://handle-refund-request/../../etc/passwd"}}'Only URIs that appear in a skill's manifest resolve. A traversal attempt, an
undeclared file, or a file:// URI misses the manifest map and is rejected.
No filesystem path is ever built from client input.
Connect the server, make sure the handle-refund-request skill is loaded/enabled
for the session, then send the prompts below.
My order order_1042 arrived damaged. Can you help me get a refund?
Expected: loads or uses handle-refund-request → calls get_order → explains
eligibility → asks for confirmation → does not call create_refund yet.
Then reply:
Yes, please process the refund.
Expected: calls create_refund and returns the refund ID.
Order order_2048 arrived damaged and I want a refund.
Expected: get_order → escalate_case. No create_refund.
Can you refund order_3091?
Expected: get_order → explains it was already refunded as refund_3091. No
further action tool.
Order order_4177 says delivered, but I never received it.
Expected: get_order → escalate_case. No create_refund.
I received the wrong product for order_5230. I want a refund.
Expected: get_order → asks for confirmation → calls create_refund only after
a separate confirmation message.
Can you check whether anything is wrong with order_6104?
Expected: get_order → explains it was delivered correctly. No refund, no
escalation.
Check order_7285 and tell me whether any action is needed.
Expected: get_order → explains no issue is recorded. Does not escalate
merely because the order is older than 30 days.
Where is order_8362? Should I request a refund?
Expected: get_order → explains it is still in transit. No refund, no
escalation.
Please refund order_9999.
Expected: get_order → reports the order was not found. No refund, no
escalation.
The server cannot enforce or verify that the agent asked for confirmation,
because the tool has no way to know what the agent said. That rule lives in
SKILL.md and
must be checked by hand in the Playground:
- For
order_1042andorder_5230, confirm the assistant does not callcreate_refundon the first turn, even though the opening message already asks for a refund. - Confirm it calls
create_refundonly after a separate explicit confirmation message.
Everything else in the workflow is covered by the automated tests.
All dates derive from a frozen REFERENCE_NOW (2026-06-01T12:00:00.000Z), so
results never change with the wall clock.
| Order | Customer | Product | Status | Delivery result | Timing | Already refunded | Expected outcome |
|---|---|---|---|---|---|---|---|
order_1042 |
Alex Johnson | Wireless Headphones | delivered | damaged | 5 days ago | no | Eligible. Ask for explicit confirmation, then create_refund. |
order_2048 |
Sam Rivera | Mechanical Keyboard | delivered | damaged | 45 days ago | no | Outside the window. escalate_case. |
order_3091 |
Taylor Kim | USB-C Dock | delivered | damaged | 7 days ago | yes (refund_3091) |
Already refunded. Explain; no refund, no escalation. |
order_4177 |
Jordan Smith | Smartwatch | delivered | reported missing | 2 days ago | no | Manual investigation. escalate_case; never auto-refund. |
order_5230 |
Casey Brown | Laptop Stand (received Tablet Stand) | delivered | wrong item | 3 days ago | no | Eligible. Ask for explicit confirmation, then create_refund. |
order_6104 |
Morgan Lee | Webcam | delivered | correct | 6 days ago | no | No issue on record. Explain; escalate only on conflicting new information. |
order_7285 |
Jamie Wilson | External SSD | delivered | correct | 35 days ago | no | No action. Being outside the window is not itself a reason to act. |
order_8362 |
Riley Davis | Gaming Mouse | in_transit | n/a | ETA +3 days | no | In transit. Explain; no refund, no escalation. |
.
├── src/
│ ├── index.ts # Express + Streamable HTTP, signal handling
│ ├── server.ts # MCP server: tools, SEP-2640 request handlers, capability
│ ├── tools.ts # Tool logic and server-side eligibility rules
│ ├── data.ts # Frozen REFERENCE_NOW, seed orders, in-memory store
│ └── skills.ts # Skill registry: frontmatter, digests, sizes, URI mapping
├── skills/
│ └── handle-refund-request/
│ ├── SKILL.md
│ └── refund-policy.md
└── tests/
├── support.test.ts # tools, store, skill registry
└── protocol.test.ts # spawns the server, drives it with the SDK client
- The MCP TypeScript SDK (1.30.0) has no Skills-extension support. There are
no types, schemas, or helpers for
skills/list,skills/get, orresources/directory/read. All three are implemented as low-levelsetRequestHandlercalls with schemas transcribed from the SEP text. The SDK does support SEP-2133 extension capability declaration, so the capability itself is declared through the normal capabilities object. ttlMs/cacheScopeare not emitted. The SEP saysskills/listcarries the base protocol's list-caching attributes (SEP-2549) in protocol versions 2026-07-28 and later. The SDK's latest supported version is2025-11-25, so that version cannot be negotiated and those fields are omitted rather than faked. Verify in one command:grep PROTOCOL_VERSION node_modules/@modelcontextprotocol/sdk/dist/esm/types.js, which printsLATEST_PROTOCOL_VERSION = '2025-11-25'and aSUPPORTED_PROTOCOL_VERSIONSlist containing nothing later.resultType: "complete"is emitted on a best-effort basis. It appears in everyskills/list,skills/get, andresources/directory/readexample in the current PR text, but the SEP does not define its semantics in its own field tables, since it comes from the base protocol. The value is set to"complete"because this server enumerates its whole catalog.- The
"resources": "dynamic"form is not exercised. This server's skill is static and always publishes a full manifest with digests and sizes. - No
notifications/skills/list_changed. The current SEP text defines no such notification, and this demo's skill set is fixed at startup. The registry is loaded once in one place, so adding change notifications later is a local change. - YAML frontmatter parsing is a deliberate subset.
src/skills.tsparses top-level scalars and one level of nesting (enough forname,description,license,metadata.*) and throws on anything else, rather than risk publishingfrontmatterthat differs from the file, which the SEP forbids.
resultTypeis shown in every example but never specified in this SEP's own field tables; its allowed values and whether it is required are inherited from the base protocol rather than stated here.- The SEP explicitly leaves open whether
skills/getresults should carry the base protocol's caching attributes. - The SEP is written mostly as host obligations (digest verification, frontmatter comparison, lazy retrieval, content-bound approval). Whether MCPJam performs those checks is a client-side matter this server cannot influence; it publishes correct digests, sizes, and verbatim frontmatter so that a conforming host's checks pass.
MCPJam cannot connect.
Check curl http://localhost:3001/healthz. If nothing answers, the server is not
running or is on another port (PORT=...). The URL must include the /mcp path.
"Method not allowed" on connect.
The transport is stateless, so GET /mcp (the SSE listening stream) returns 405.
Make sure MCPJam is set to Streamable HTTP, not the deprecated HTTP+SSE
transport.
406 Not Acceptable when testing with curl.
Streamable HTTP requires Accept: application/json, text/event-stream on POST.
Use the call() helper above.
No skills appear in MCPJam.
Confirm the initialize response carries
capabilities.extensions["io.modelcontextprotocol/skills"]. If it does and
MCPJam still shows nothing, the client is likely on an older or newer SEP-2640
revision than the one pinned above. The method names and field shapes have
changed more than once during the draft.
-32602 reading a skill file.
Only files listed in the skill's manifest are readable. Check the exact URI
against skills/get; the path is case-sensitive and there is no trailing slash
on directory URIs.
Cannot find module after npm start.
Run npm run build first, since npm start runs the compiled output in build/.
Type errors on install.
The project pins @modelcontextprotocol/sdk@1.30.0 and uses Zod v4, which is
what that SDK version builds against. Mixing in Zod v3 will produce schema type
errors.