Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/skills-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640) as a pair of extensions.

`@modelcontextprotocol/server/ext/skills`: `new SkillsExtension(source)` in `ServerOptions.extensions` declares the extension and the `resources` capability, and serves `skills/list` and `skills/get` from a `SkillSource`, plus `resources/directory/read` (declared as `directoryRead`) when the source implements `readDirectory`. Entries are checked against the specification's structural rules before they are sent. Skill files stay ordinary resources; `skillResourceOf` computes the digest and size an entry lists for each.

`@modelcontextprotocol/client/ext/skills`: `new SkillsClientExtension()` in `ClientOptions.extensions` wraps `list`, `get` and `readDirectory`, each refused unless the server declared support, and `read(skill, uri)`, which fetches a skill file and verifies it is listed and matches the entry's size and digest.

Wire types and zod schemas live at `@modelcontextprotocol/core-internal/ext/skills` and are re-exported from both subpaths.
2 changes: 2 additions & 0 deletions docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Elicitation', link: '/servers/elicitation' },
{ text: 'Sampling (sunset)', link: '/servers/sampling' },
{ text: 'Input required', link: '/servers/input-required' },
{ text: 'Skills (extension)', link: '/servers/skills' },
{ text: 'Notifications', link: '/servers/notifications' },
{ text: 'Errors', link: '/servers/errors' }
]
Expand All @@ -53,6 +54,7 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Handle server requests', link: '/clients/server-requests' },
{ text: 'Roots (sunset)', link: '/clients/roots' },
{ text: 'Subscriptions', link: '/clients/subscriptions' },
{ text: 'Skills (extension)', link: '/clients/skills' },
{ text: 'OAuth', link: '/clients/oauth' },
{ text: 'Machine auth', link: '/clients/machine-auth' },
{ text: 'Middleware', link: '/clients/middleware' },
Expand Down
39 changes: 39 additions & 0 deletions docs/clients/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
shape: how-to
---

# Skills (extension)

The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/). `@modelcontextprotocol/client/ext/skills` is the client side, as a client extension: it lists and gets skill entries, reads directories, and reads skill files verified against their entry.

## Install the extension

```ts
import { Client } from '@modelcontextprotocol/client';
import { SkillsClientExtension } from '@modelcontextprotocol/client/ext/skills';

const skills = new SkillsClientExtension();
const client = new Client({ name: 'host', version: '1.0.0' }, { extensions: [skills] });
await client.connect(transport);
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): readers of the client docs are told every method is refused unless the server declared the extension, but read is not gated. docs/clients/skills.md:20 says "Every method refuses with CapabilityNotSupported", while read at packages/client/src/ext/skills/skillsClientExtension.ts:108 never calls #require and issues resources/read regardless of what the server declared. Fix: make the words match the code, either by saying list, get and readDirectory are refused (as the changeset and the class JSDoc already do) or by gating read on the same check. [also at: docs/clients/skills.md:20 - nit: CLAUDE.md asks that docs say what the code does: this line states "Every method refuses with CapabilityNotSupported unless the server declared the extension and the resources capability", but SkillsClientExtension.read() (packages/client/src/ext/skills/skillsClientExtension.ts:108-120) never calls #require — it goes straight to client.readResource, so a server that declared resources but not the skills extension is read from without refusal.]

Why this was flagged

A user reads docs/clients/skills.md:20 and expects skills.read(skill, uri) to throw CapabilityNotSupported against a server that did not declare io.modelcontextprotocol/skills. The method at packages/client/src/ext/skills/skillsClientExtension.ts:108-120 performs no capability check and calls this.client.readResource directly, so the call proceeds and either succeeds or fails with whatever resources/read returns. The changeset and the class JSDoc at skillsClientExtension.ts:4-6 describe only list, get and readDirectory as refused, so the docs page alone is wrong. Nothing breaks at runtime; the doc misstates the behaviour.

Verification: nit. The doc line is docs/clients/skills.md:20: "Every method refuses with CapabilityNotSupported unless the server declared the extension and the resources capability." In packages/client/src/ext/skills/skillsClientExtension.ts, list (line 73), get (line 83) and readDirectory (line 89) call this.#require(...), but read (lines 108-120) never does. Nothing breaks at runtime, so this is a documentation inaccuracy.

Every method refuses with `CapabilityNotSupported` unless the server declared the extension and the `resources` capability. `readDirectory` also needs the server to declare `directoryRead`.

## List and get skills

`list` returns one page of entries. Pass `nextCursor` back as `cursor` for the next page. An entry is the full manifest: frontmatter, plus every file with its digest and size. `get(uri)` returns the entry for one skill by the URI of its `SKILL.md`, whether or not the listing included it.

```ts
const { skills: entries, nextCursor } = await skills.list();
const { skill } = await skills.get('skill://git-workflow/SKILL.md');
```

## Read a skill file

`read(skill, uri)` fetches the file with `resources/read` and checks it against the entry. The file must be listed, and its size and SHA-256 digest must match. Any mismatch throws `SdkError` with `InvalidResult`. Refresh the entry with `get` and ask the user to approve the skill again.

```ts
const manifest = await skills.read(skill, skill.uri);
```

A skill with `resources: 'dynamic'` has nothing to verify against, so its files come back as read. The extension leaves two checks to the host: comparing a `SKILL.md`'s frontmatter with the entry (this needs a YAML parser), and the specification's [security requirements](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx#security-considerations) for loading skills into a model.
57 changes: 57 additions & 0 deletions docs/servers/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
shape: how-to
---

# Skills (extension)

The [MCP Skills extension](https://github.com/modelcontextprotocol/ext-skills) (`io.modelcontextprotocol/skills`) lets a server publish [Agent Skills](https://agentskills.io/): directories of instructions, each with a `SKILL.md`, that a host can load into its model. `@modelcontextprotocol/server/ext/skills` is the server side, as a [server extension](../advanced/extensions.md). It serves `skills/list`, `skills/get` and, optionally, `resources/directory/read`. Your server supplies the skill entries through a `SkillSource`, and serves the skill files as ordinary resources.

## Describe a skill

A skill entry names its `SKILL.md`, repeats that file's frontmatter, and lists every file of the skill with its SHA-256 digest and size. `skillResourceOf(uri, content)` computes a file's line in that list.

```ts
import type { Skill } from '@modelcontextprotocol/server/ext/skills';
import { skillResourceOf } from '@modelcontextprotocol/server/ext/skills';

const files = {
'skill://git-workflow/SKILL.md': '---\nname: git-workflow\ndescription: Follow our Git conventions\n---\n\nBranch from main.\n'
};

const gitWorkflow: Skill = {
uri: 'skill://git-workflow/SKILL.md',
frontmatter: { name: 'git-workflow', description: 'Follow our Git conventions' },
resources: await Promise.all(Object.entries(files).map(([uri, text]) => skillResourceOf(uri, text)))
};
```

The frontmatter must match the `SKILL.md` exactly, and the URI's last directory must equal `frontmatter.name`. The server checks the second rule, and the entry's other structural rules, and answers `-32603` rather than send an entry that breaks one. A skill generated per request, with no stable digests, sets `resources: 'dynamic'`.

## Install the extension

`SkillsExtension` takes the source. `list` returns a page of entries (an empty or partial listing is allowed). `get` answers for any skill the server serves, listed or not, and returns `undefined` for anything else, which the client receives as `-32602`.

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { SkillsExtension } from '@modelcontextprotocol/server/ext/skills';

const skills = new SkillsExtension(
{
list: () => ({ skills: [gitWorkflow] }),
get: ({ uri }) => (uri === gitWorkflow.uri ? gitWorkflow : undefined)
},
{ cacheHint: { ttlMs: 300_000, cacheScope: 'public' } }
);

const server = new McpServer({ name: 'skills-server', version: '1.0.0' }, { extensions: [skills] });

for (const [uri, text] of Object.entries(files)) {
server.registerResource(uri, uri, { mimeType: 'text/markdown' }, () => ({ contents: [{ uri, mimeType: 'text/markdown', text }] }));
}
```

The server declares `io.modelcontextprotocol/skills` under `capabilities.extensions`, along with the `resources` capability the extension requires. `cacheHint` sets the `ttlMs` and `cacheScope` on `skills/list` and `skills/get` results. Both default to `0` and `'private'`.

## Serve directory reads

A source that implements `readDirectory` also gets `resources/directory/read`, and the server declares `directoryRead: true`. It returns the direct children of a directory such as `skill://pdf-processing/templates`, with subdirectories marked `mimeType: 'inode/directory'`, or `undefined` when the URI is not a directory.
13 changes: 13 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"default": "./dist/stdio.cjs"
}
},
"./ext/skills": {
"import": {
"types": "./dist/ext/skills/index.d.mts",
"default": "./dist/ext/skills/index.mjs"
},
"require": {
"types": "./dist/ext/skills/index.d.cts",
"default": "./dist/ext/skills/index.cjs"
}
},
"./validators/ajv": {
"import": {
"types": "./dist/validators/ajv.d.mts",
Expand Down Expand Up @@ -115,6 +125,9 @@
],
"stdio": [
"dist/stdio.d.mts"
],
"ext/skills": [
"dist/ext/skills/index.d.mts"
]
}
},
Expand Down
44 changes: 44 additions & 0 deletions packages/client/src/ext/skills/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `@modelcontextprotocol/client/ext/skills` — the client side of the MCP
* Skills extension (`io.modelcontextprotocol/skills`, SEP-2640).
*
* `SkillsClientExtension` wraps `skills/list`, `skills/get` and
* `resources/directory/read`, and reads skill files verified against their
* entry's digest and size.
*/

export { SkillsClientExtension } from './skillsClientExtension';
export type {
GetSkillParams,
GetSkillResult,
ListSkillsParams,
ListSkillsResult,
ReadResourceDirectoryParams,
ReadResourceDirectoryResult,
Skill,
SkillFrontmatter,
SkillResource,
SkillsCacheScope,
SkillsExtensionCapability
} from '@modelcontextprotocol/core-internal/ext/skills';
export {
getSkillParamsSchema,
getSkillResultSchema,
listSkillsParamsSchema,
listSkillsResultSchema,
readResourceDirectoryParamsSchema,
readResourceDirectoryResultSchema,
skillFrontmatterSchema,
skillResourceSchema,
skillSchema,
skillsExtensionCapabilitySchema
} from '@modelcontextprotocol/core-internal/ext/skills';
export {
DIRECTORY_MIME_TYPE,
MAX_SKILL_RESOURCES,
MAX_SKILL_TOTAL_BYTES,
SKILL_MANIFEST_FILENAME,
skillDigest,
skillResourceOf,
SKILLS_EXTENSION_ID
Comment on lines +24 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers take on a wider public API than the feature needs, since both subpaths re-export every zod schema and constant from core-internal. The client subpath at packages/client/src/ext/skills/index.ts:24-43 exports params schemas (listSkillsParamsSchema, getSkillParamsSchema, readResourceDirectoryParamsSchema) a client never parses, and MAX_SKILL_RESOURCES / MAX_SKILL_TOTAL_BYTES, which no SDK code enforces. The server subpath mirrors this with result schemas at packages/server/src/ext/skills/index.ts:26-45. Fix: export from each subpath only what its users call (types, SkillsExtension/SkillsClientExtension, skillResourceOf, skillDigest, SKILLS_EXTENSION_ID, DIRECTORY_MIME_TYPE), or state in the PR why each schema and limit constant is public.

Why this was flagged

A user imports @ modelcontextprotocol/client/ext/skills or @ modelcontextprotocol/server/ext/skills. Each index (packages/client/src/ext/skills/index.ts:24-43, packages/server/src/ext/skills/index.ts:26-45) re-exports all ten zod schemas and all five constants from @ modelcontextprotocol/core-internal/ext/skills, including params schemas on the client side and result schemas on the server side that the respective extension never uses, plus MAX_SKILL_RESOURCES and MAX_SKILL_TOTAL_BYTES, which packages/core-internal/src/ext/skills/schemas.ts:8-9 says are deliberately not enforced and which only a test reads. Once published these become semver-bound surface that the SDK must keep. On the base branch neither subpath exists, so nothing is exposed. CLAUDE.md principle 1 asks to prefer changes that add no API unless justified; the PR description gives no reason for exporting the schemas or limits. Nothing fails at runtime.

Verification: nit. Triggering condition: any consumer of the new subpaths sees the full re-exported surface. Mechanism verified: packages/client/src/ext/skills/index.ts:24-44 re-exports all ten zod schemas and all five constants; packages/server/src/ext/skills/index.ts:26-46 does the same. Nothing fails at runtime; this is API-surface commitment only, hence nit.

} from '@modelcontextprotocol/core-internal/ext/skills';
133 changes: 133 additions & 0 deletions packages/client/src/ext/skills/skillsClientExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* `SkillsClientExtension` — the client side of the MCP Skills extension
* (`io.modelcontextprotocol/skills`, SEP-2640) as a {@linkcode ClientExtension}.
* It wraps `skills/list`, `skills/get` and `resources/directory/read`, each
* refused unless the server declared support, and `read`, which fetches a
* skill file with `resources/read` and verifies it against the skill's entry.
*
* ```ts
* const skills = new SkillsClientExtension();
* const client = new Client(info, { extensions: [skills] });
* await client.connect(transport);
*
* const { skills: entries } = await skills.list();
* const manifest = await skills.read(entries[0], entries[0].uri);
* ```
*
* One extension instance serves one client: `install` binds it.
*/

import type { BlobResourceContents, RequestOptions, TextResourceContents } from '@modelcontextprotocol/core-internal';
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import type {
GetSkillResult,
ListSkillsParams,
ListSkillsResult,
ReadResourceDirectoryResult,
Skill,
SkillsExtensionCapability
} from '@modelcontextprotocol/core-internal/ext/skills';
import {
getSkillResultSchema,
listSkillsResultSchema,
readResourceDirectoryResultSchema,
skillDigest,
skillFileBytes,
SKILLS_EXTENSION_ID,
skillsExtensionCapabilitySchema
} from '@modelcontextprotocol/core-internal/ext/skills';

import type { Client } from '../../client/client';
import type { ClientExtension } from '../../client/extension';

const verificationFailure = (uri: string, reason: string): never => {
throw new SdkError(SdkErrorCode.InvalidResult, `Skill file ${uri} failed verification: ${reason}`);
};

const rawBytes = (content: TextResourceContents | BlobResourceContents): Uint8Array =>
'blob' in content ? Uint8Array.from(atob(content.blob), char => char.codePointAt(0) ?? 0) : skillFileBytes(content.text);

export class SkillsClientExtension implements ClientExtension {
readonly id = SKILLS_EXTENSION_ID;
#client: Client | undefined;

install(client: Client): void {
if (this.#client !== undefined) throw new Error('SkillsClientExtension is already installed on a client');
this.#client = client;
}

get client(): Client {
if (this.#client === undefined) throw new SdkError(SdkErrorCode.NotConnected, 'SkillsClientExtension is not installed on a client');
return this.#client;
}

/** The extension settings the server declared, or `undefined` when it declared none. */
get capability(): SkillsExtensionCapability | undefined {
const declared = this.client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION_ID];
const parsed = skillsExtensionCapabilitySchema.safeParse(declared);
return parsed.success ? parsed.data : undefined;
}

/** `skills/list`: one page of entries. Pass `nextCursor` back as `cursor` for the next. */
async list(params?: ListSkillsParams, options?: RequestOptions): Promise<ListSkillsResult> {
this.#require('skills/list');
return (await this.client.request(
{ method: 'skills/list', params: { ...params } },
listSkillsResultSchema,
options
)) as ListSkillsResult;
}

/** `skills/get`: the entry for the skill whose `SKILL.md` is at `uri`, listed or not. */
async get(uri: string, options?: RequestOptions): Promise<GetSkillResult> {
this.#require('skills/get');
return (await this.client.request({ method: 'skills/get', params: { uri } }, getSkillResultSchema, options)) as GetSkillResult;
}

/** `resources/directory/read`: the direct children of a directory resource. Refused unless the server declared `directoryRead`. */
async readDirectory(uri: string, cursor?: string, options?: RequestOptions): Promise<ReadResourceDirectoryResult> {
this.#require('resources/directory/read');
if (this.capability?.directoryRead !== true) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Server does not support resources/directory/read');
}
const params = { uri, ...(cursor !== undefined && { cursor }) };
return (await this.client.request(
{ method: 'resources/directory/read', params },
readResourceDirectoryResultSchema,
options
)) as ReadResourceDirectoryResult;
}

/**
* Reads one of `skill`'s files and verifies it against the entry: the file
* must be listed, and its size and digest must match. Throws `InvalidResult`
* on any mismatch. A `"dynamic"` skill has nothing to verify against, so its
* files are returned as read. Comparing a `SKILL.md`'s frontmatter with the
* entry needs a YAML parser and is left to the host.
*/
async read(skill: Skill, uri: string, options?: RequestOptions): Promise<TextResourceContents | BlobResourceContents> {
const listed = skill.resources === 'dynamic' ? undefined : skill.resources.find(resource => resource.uri === uri);
if (skill.resources !== 'dynamic' && listed === undefined) verificationFailure(uri, `not listed in ${skill.uri}`);

const { contents } = await this.client.readResource({ uri }, options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Hosts whose client cached a skill file keep getting InvalidResult from read after the skill is updated, even after refreshing the entry as the docs advise. packages/client/src/ext/skills/skillsClientExtension.ts:112 calls readResource with plain RequestOptions, so a still-fresh cached body (stored when the server set a positive ttlMs on resources/read) is served and compared against the new entry's digest, and get cannot clear it. Fix: on a size or digest mismatch against a cache-served body, evict and re-read once with cacheMode: 'refresh' (as callTool does for a stale schema) before failing, and accept CacheableRequestOptions so hosts can bypass the cache themselves. The failure lasts until the body's TTL expires, up to MAX_CACHE_TTL_MS (24 h). [also at: packages/client/src/ext/skills/skillsClientExtension.ts:118 - Hosts whose client cached a skill file get a spurious verification failure from read after the skill changes, and the documented recovery (refresh the entry with get) cannot clear it. skillsClientExtension.ts:112 calls readResource with plain RequestOptions, so a resources/read body cached under a positive ttlMs is served instead of refetched, then fails the new entry's size/digest check at :117-118.]

Why this was flagged

A server registers a skill file with registerResource(uri, uri, { cacheHint: { ttlMs: 300_000, cacheScope: 'public' } }, cb); packages/server/src/server/mcp.ts:598 attaches that hint and the client's readResource at packages/client/src/client/client.ts:1912-1914 stores the body. The operator then changes the file and the entry's digest. The host calls skills.list() and gets the new digest, then skills.read(skill, uri). packages/client/src/ext/skills/skillsClientExtension.ts:112 passes options typed RequestOptions (no cacheMode), so client.ts:1902 _serveFromCache returns the old body; line 117/118 then throw SdkError(InvalidResult, 'digest mismatch'). docs/clients/skills.md:33 tells the host to refresh the entry with get, but get touches no resources/read cache key, so every retry fails the same way until the TTL (capped at MAX_CACHE_TTL_MS = 86_400_000) expires. The base branch has no read, so hosts there hand-roll readResource and can pass cacheMode.

Verification: normal — when a server attaches a positive ttlMs cache hint to a skill file and later updates it within the TTL. skillsClientExtension.ts:112 calls readResource with options?: RequestOptions; client.ts:1901-1903 serves a still-fresh cached body first. skills/get never evicts the response cache, so the recovery docs/clients/skills.md:33 advises does not clear it; lines 117-118 then throw InvalidResult until the TTL expires.

const content = contents.find(item => item.uri === uri) ?? verificationFailure(uri, 'not in the resources/read result');
if (listed === undefined) return content;

const bytes = rawBytes(content);
if (bytes.byteLength !== listed.size) verificationFailure(uri, `size ${bytes.byteLength}, entry says ${listed.size}`);
if ((await skillDigest(bytes)) !== listed.digest) verificationFailure(uri, 'digest mismatch');
return content;
}

#require(method: string): void {
if (this.capability === undefined) {
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support the ${SKILLS_EXTENSION_ID} extension (${method})`
);
}
if (this.client.getServerCapabilities()?.resources === undefined) {
throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server declares ${SKILLS_EXTENSION_ID} without resources (${method})`);
}
}
}
3 changes: 2 additions & 1 deletion packages/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"./node_modules/@modelcontextprotocol/core-internal/src/validators/cfWorkerProvider.ts"
],
"@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"],
"@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"]
"@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"],
"@modelcontextprotocol/core-internal/ext/skills": ["./node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts"]
}
}
}
2 changes: 2 additions & 0 deletions packages/client/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default defineConfig({
entry: [
'src/index.ts',
'src/stdio.ts',
'src/ext/skills/index.ts',
'src/shimsNode.ts',
'src/shimsWorkerd.ts',
'src/shimsBrowser.ts',
Expand All @@ -28,6 +29,7 @@ export default defineConfig({
'fast-uri': ['../core-internal/src/validators/fastUriShim.d.ts'],
'@modelcontextprotocol/core-internal': ['../core-internal/src/index.ts'],
'@modelcontextprotocol/core-internal/public': ['../core-internal/src/exports/public/index.ts'],
'@modelcontextprotocol/core-internal/ext/skills': ['../core-internal/src/ext/skills/index.ts'],
'@modelcontextprotocol/core-internal/validators/ajv': ['../core-internal/src/validators/ajvProvider.ts'],
'@modelcontextprotocol/core-internal/validators/cfWorker': ['../core-internal/src/validators/cfWorkerProvider.ts']
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core-internal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"types": "./src/exports/public/index.ts",
"import": "./src/exports/public/index.ts"
},
"./ext/skills": {
"types": "./src/ext/skills/index.ts",
"import": "./src/ext/skills/index.ts"
},
"./validators/ajv": {
"types": "./src/validators/ajvProvider.ts",
"import": "./src/validators/ajvProvider.ts"
Expand Down
20 changes: 20 additions & 0 deletions packages/core-internal/src/ext/skills/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SkillResource } from './types';

const encoder = new TextEncoder();

/** The raw bytes of a file: UTF-8 for text. */
export function skillFileBytes(content: string | Uint8Array): Uint8Array {
return typeof content === 'string' ? encoder.encode(content) : content;
}

/** `sha256:{hex}` of raw bytes, the digest format of a `SkillResource`. */
export async function skillDigest(bytes: Uint8Array): Promise<string> {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>));
return `sha256:${Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('')}`;
}

/** The `SkillResource` entry for a file: its URI, digest and size. */
export async function skillResourceOf(uri: string, content: string | Uint8Array): Promise<SkillResource> {
const bytes = skillFileBytes(content);
return { uri, digest: await skillDigest(bytes), size: bytes.byteLength };
}
Loading
Loading