Skip to content

Commit 09ea2f9

Browse files
olaservoclaude
andcommitted
feat(skills): sync with SEP-2640 upstream b405ba5
Entry resources are now {uri, digest, size} triples, and the resources field always serializes as an array or the literal string "dynamic". Enforce the SEP's per-skill limits (512 files, 16 MiB) in skills.New and pre-check them from tree metadata in RepoSkillEntry before fetching any blob. Existing-but-empty directories answer with an empty resources array instead of -32602. The extension's results carry the base protocol's resultType marker on 2026-07-28+ sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ff8YDXkcySZpDBVbHkEtu5
1 parent 3784bcd commit 09ea2f9

6 files changed

Lines changed: 226 additions & 19 deletions

File tree

pkg/github/skills_dynamic.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ func RepoSkillEntry(ctx context.Context, uri string) (*skills.Entry, error) {
4747
return nil, nil // skill not found in the repository
4848
}
4949

50+
// Enforce the SEP-2640 per-skill limits from the tree metadata before
51+
// fetching a single blob, so an oversized skill costs one tree call,
52+
// not one fetch per file.
53+
var fileCount int
54+
var totalBytes int64
55+
for _, entry := range entries {
56+
if entry.GetType() != "blob" || !strings.HasPrefix(entry.GetPath(), dir+"/") {
57+
continue
58+
}
59+
fileCount++
60+
totalBytes += int64(entry.GetSize())
61+
}
62+
if fileCount > skills.MaxFilesPerSkill || totalBytes > skills.MaxSkillBytes {
63+
return nil, &jsonrpc.Error{
64+
Code: jsonrpc.CodeInvalidParams,
65+
Message: fmt.Sprintf("skill %q exceeds the SEP-2640 per-skill limits (%d files, %d bytes)", skillName, fileCount, totalBytes),
66+
}
67+
}
68+
5069
// Fetch every file in the skill directory and digest its raw bytes.
5170
// Skills are small by design (a SKILL.md plus a handful of supporting
5271
// files), so one blob fetch per file is acceptable for skills/get,
@@ -135,7 +154,9 @@ func RepoSkillDirectory(ctx context.Context, uri string) ([]*mcp.Resource, error
135154
target = dir + "/" + subPath
136155
}
137156

138-
var children []*mcp.Resource
157+
// Non-nil so an empty directory answers with an empty resources array
158+
// rather than reading as "not a directory this server serves".
159+
children := []*mcp.Resource{}
139160
targetExists := subPath == "" // the skill root exists by discovery
140161
for _, entry := range entries {
141162
entryPath := entry.GetPath()

pkg/github/skills_dynamic_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,32 @@ func Test_RepoSkillEntry(t *testing.T) {
7272
require.Len(t, entry.Resources, 2)
7373
assert.Equal(t, entry.URI, entry.Resources[0].URI)
7474
assert.Equal(t, skills.Digest([]byte(repoSkillMD)), entry.Resources[0].Digest)
75+
assert.Equal(t, int64(len(repoSkillMD)), entry.Resources[0].Size)
7576
assert.Equal(t, "skill://octocat/hello-world/my-skill/references/GUIDE.md", entry.Resources[1].URI)
7677
assert.Equal(t, skills.Digest([]byte(repoGuideMD)), entry.Resources[1].Digest)
78+
assert.Equal(t, int64(len(repoGuideMD)), entry.Resources[1].Size)
79+
})
80+
81+
t.Run("-32602 for a skill over the size limit, without fetching blobs", func(t *testing.T) {
82+
handlers := repoSkillHandlers(repoSkillMD)
83+
handlers[GetReposGitTreesByOwnerByRepoByTree] = func(w http.ResponseWriter, _ *http.Request) {
84+
tree := &gogithub.Tree{Entries: []*gogithub.TreeEntry{
85+
{Path: gogithub.Ptr("skills/my-skill/SKILL.md"), Type: gogithub.Ptr("blob"), SHA: gogithub.Ptr("sha-skill"), Size: gogithub.Ptr(skills.MaxSkillBytes + 1)},
86+
}}
87+
data, _ := json.Marshal(tree)
88+
w.Header().Set("Content-Type", "application/json")
89+
_, _ = w.Write(data)
90+
}
91+
handlers[getReposGitBlobsBySHA] = func(w http.ResponseWriter, _ *http.Request) {
92+
t.Fatal("blob fetched for a skill that fails the limit pre-check")
93+
}
94+
ctx := dynamicTestContext(t, handlers)
95+
_, err := RepoSkillEntry(ctx, "skill://octocat/hello-world/my-skill/SKILL.md")
96+
require.Error(t, err)
97+
var jsonrpcErr *jsonrpc.Error
98+
require.ErrorAs(t, err, &jsonrpcErr)
99+
assert.EqualValues(t, jsonrpc.CodeInvalidParams, jsonrpcErr.Code)
100+
assert.Contains(t, jsonrpcErr.Message, "limit")
77101
})
78102

79103
t.Run("nil for URIs that are not a SKILL.md", func(t *testing.T) {

skills/methods.go

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ const (
2525
// property — digest verification governs content regardless.
2626
const listTTLMs = 3_600_000
2727

28+
// resultTypeComplete is the base protocol's multi-round-trip result marker
29+
// (SEP-2322): results on 2026-07-28+ sessions declare themselves "complete".
30+
// The SDK stamps its own result types automatically but not custom methods',
31+
// so the extension's handlers set it themselves.
32+
const (
33+
resultTypeComplete = "complete"
34+
protocolVersion20260728 = "2026-07-28"
35+
)
36+
37+
// completeResultType returns the resultType value for a session: "complete"
38+
// when the session negotiated 2026-07-28 or later, empty (omitted) for older
39+
// clients, for which the field does not exist.
40+
func completeResultType(ss *mcp.ServerSession) string {
41+
if ss != nil {
42+
if p := ss.InitializeParams(); p != nil && p.ProtocolVersion < protocolVersion20260728 {
43+
return ""
44+
}
45+
}
46+
return resultTypeComplete
47+
}
48+
2849
// ListSkillsParams is the skills/list request payload.
2950
type ListSkillsParams struct {
3051
mcp.ParamsBase
@@ -36,7 +57,10 @@ type ListSkillsParams struct {
3657
type ListSkillsResult struct {
3758
mcp.ResultBase
3859
mcp.Cacheable
39-
Skills []Entry `json:"skills"`
60+
// ResultType is the base protocol's completion marker on 2026-07-28+
61+
// sessions; see completeResultType.
62+
ResultType string `json:"resultType,omitempty"`
63+
Skills []Entry `json:"skills"`
4064
// NextCursor, when present, indicates more entries are available.
4165
NextCursor string `json:"nextCursor,omitempty"`
4266
}
@@ -52,7 +76,8 @@ type GetSkillParams struct {
5276
// shape and meaning to an entry of skills/list.
5377
type GetSkillResult struct {
5478
mcp.ResultBase
55-
Skill Entry `json:"skill"`
79+
ResultType string `json:"resultType,omitempty"`
80+
Skill Entry `json:"skill"`
5681
}
5782

5883
// DirectoryReadParams is the resources/directory/read request payload.
@@ -70,7 +95,8 @@ type DirectoryReadParams struct {
7095
// mimeType "inode/directory".
7196
type DirectoryReadResult struct {
7297
mcp.ResultBase
73-
Resources []*mcp.Resource `json:"resources"`
98+
ResultType string `json:"resultType,omitempty"`
99+
Resources []*mcp.Resource `json:"resources"`
74100
// NextCursor, when present, indicates more children are available.
75101
NextCursor string `json:"nextCursor,omitempty"`
76102
}
@@ -138,8 +164,8 @@ func serveFile(uri string, f File) mcp.ResourceHandler {
138164
// so it always fits one page: any cursor is accepted and the full listing
139165
// returned with no nextCursor, which terminates every conforming pagination
140166
// loop.
141-
func (p *Publisher) listSkills(_ context.Context, _ *mcp.ServerSession, _ *ListSkillsParams) (*ListSkillsResult, error) {
142-
res := &ListSkillsResult{Skills: p.Registry.Entries()}
167+
func (p *Publisher) listSkills(_ context.Context, ss *mcp.ServerSession, _ *ListSkillsParams) (*ListSkillsResult, error) {
168+
res := &ListSkillsResult{Skills: p.Registry.Entries(), ResultType: completeResultType(ss)}
143169
res.TTLMs = listTTLMs
144170
res.CacheScope = "public"
145171
return res, nil
@@ -148,42 +174,42 @@ func (p *Publisher) listSkills(_ context.Context, _ *mcp.ServerSession, _ *ListS
148174
// getSkill implements skills/get. Per the SEP it answers for every skill the
149175
// server serves — listed or not — and returns -32602 (the same code
150176
// resources/read uses for unknown resources) otherwise.
151-
func (p *Publisher) getSkill(ctx context.Context, _ *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) {
177+
func (p *Publisher) getSkill(ctx context.Context, ss *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) {
152178
if params == nil || params.URI == "" {
153179
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "missing required parameter: uri"}
154180
}
155181
if s, ok := p.Registry.Get(params.URI); ok {
156-
return &GetSkillResult{Skill: s.Entry()}, nil
182+
return &GetSkillResult{Skill: s.Entry(), ResultType: completeResultType(ss)}, nil
157183
}
158184
if p.DynamicGet != nil {
159185
entry, err := p.DynamicGet(ctx, params.URI)
160186
if err != nil {
161187
return nil, err
162188
}
163189
if entry != nil {
164-
return &GetSkillResult{Skill: *entry}, nil
190+
return &GetSkillResult{Skill: *entry, ResultType: completeResultType(ss)}, nil
165191
}
166192
}
167193
return nil, mcp.ResourceNotFoundError(params.URI)
168194
}
169195

170196
// readDirectory implements resources/directory/read. Like listSkills, bundled
171197
// directories are small enough to always fit one page.
172-
func (p *Publisher) readDirectory(ctx context.Context, _ *mcp.ServerSession, params *DirectoryReadParams) (*DirectoryReadResult, error) {
198+
func (p *Publisher) readDirectory(ctx context.Context, ss *mcp.ServerSession, params *DirectoryReadParams) (*DirectoryReadResult, error) {
173199
if params == nil || params.URI == "" {
174200
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "missing required parameter: uri"}
175201
}
176202
uri := strings.TrimSuffix(params.URI, "/")
177203
if children, ok := p.Registry.Directory(uri); ok {
178-
return &DirectoryReadResult{Resources: children}, nil
204+
return &DirectoryReadResult{Resources: children, ResultType: completeResultType(ss)}, nil
179205
}
180206
if p.DynamicDirectoryRead != nil {
181207
children, err := p.DynamicDirectoryRead(ctx, uri)
182208
if err != nil {
183209
return nil, err
184210
}
185211
if children != nil {
186-
return &DirectoryReadResult{Resources: children}, nil
212+
return &DirectoryReadResult{Resources: children, ResultType: completeResultType(ss)}, nil
187213
}
188214
}
189215
return nil, mcp.ResourceNotFoundError(params.URI)

skills/methods_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func TestSkillsListOverProtocol(t *testing.T) {
7878
assert.Empty(t, res.NextCursor, "an entry is atomic and the catalog fits one page")
7979
assert.Positive(t, res.TTLMs)
8080
assert.Equal(t, "public", res.CacheScope)
81+
assert.Equal(t, "complete", res.ResultType)
8182

8283
// Each SKILL.md listed is readable via plain resources/read, and its
8384
// content matches the advertised digest.
@@ -87,6 +88,7 @@ func TestSkillsListOverProtocol(t *testing.T) {
8788
require.Len(t, rr.Contents, 1)
8889
sum := sha256.Sum256([]byte(rr.Contents[0].Text))
8990
assert.Equal(t, entry.Resources[0].Digest, "sha256:"+hex.EncodeToString(sum[:]))
91+
assert.Equal(t, int64(len(rr.Contents[0].Text)), entry.Resources[0].Size)
9092

9193
// Frontmatter identity requirement: a host parsing the fetched
9294
// SKILL.md must find frontmatter identical to the entry's.
@@ -108,6 +110,7 @@ func TestSkillsGetOverProtocol(t *testing.T) {
108110
assert.Equal(t, "skill://acme/pdf-processing/SKILL.md", res.Skill.URI)
109111
assert.Equal(t, "pdf-processing", res.Skill.Frontmatter["name"])
110112
assert.Len(t, res.Skill.Resources, 6)
113+
assert.Equal(t, "complete", res.ResultType)
111114
})
112115

113116
t.Run("unknown skill is -32602", func(t *testing.T) {
@@ -134,6 +137,7 @@ func TestDirectoryReadOverProtocol(t *testing.T) {
134137
&skills.DirectoryReadParams{URI: "skill://acme/pdf-processing/templates"})
135138
require.NoError(t, err)
136139
require.Len(t, res.Resources, 3)
140+
assert.Equal(t, "complete", res.ResultType)
137141
assert.Equal(t, "regional", res.Resources[2].Name)
138142
assert.Equal(t, skills.DirectoryMIMEType, res.Resources[2].MIMEType)
139143

@@ -183,7 +187,9 @@ func TestDynamicHooks(t *testing.T) {
183187
dynamicEntry := skills.Entry{
184188
URI: "skill://dyn/generated/SKILL.md",
185189
Frontmatter: map[string]any{"name": "generated", "description": "made on demand"},
186-
// No resources: dynamically generated content cannot be pre-digested.
190+
// Dynamically generated content cannot be pre-digested; the entry's
191+
// resources field serializes as the string "dynamic".
192+
Dynamic: true,
187193
}
188194
cs := connect(t, testFS, "acme", func(p *skills.Publisher) {
189195
p.DynamicGet = func(_ context.Context, uri string) (*skills.Entry, error) {
@@ -206,6 +212,8 @@ func TestDynamicHooks(t *testing.T) {
206212
ctx, cs, skills.MethodSkillsGet, &skills.GetSkillParams{URI: dynamicEntry.URI})
207213
require.NoError(t, err)
208214
assert.Equal(t, dynamicEntry.URI, res.Skill.URI)
215+
// The "dynamic" marker survives the wire roundtrip.
216+
assert.True(t, res.Skill.Dynamic)
209217
assert.Empty(t, res.Skill.Resources)
210218
})
211219

skills/skill.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"bytes"
1515
"crypto/sha256"
1616
"encoding/hex"
17+
"encoding/json"
1718
"fmt"
1819
"mime"
1920
"path"
@@ -36,6 +37,12 @@ const (
3637
// DirectoryMIMEType marks directory resources in
3738
// resources/directory/read listings.
3839
DirectoryMIMEType = "inode/directory"
40+
41+
// MaxFilesPerSkill and MaxSkillBytes are the SEP-2640 per-skill limits:
42+
// conforming hosts must accept skills up to these bounds, and servers
43+
// should not serve skills beyond them.
44+
MaxFilesPerSkill = 512
45+
MaxSkillBytes = 16 * 1024 * 1024
3946
)
4047

4148
// File is a single file of a skill, addressed relative to the skill directory.
@@ -80,29 +87,84 @@ func (s *Skill) RootURI() string { return "skill://" + s.Path }
8087
// FileURI returns the resource URI of a file within the skill directory.
8188
func (s *Skill) FileURI(relPath string) string { return "skill://" + s.Path + "/" + relPath }
8289

83-
// EntryResource is one {uri, digest} pair of a skill entry's resources set.
90+
// EntryResource is one {uri, digest, size} triple of a skill entry's
91+
// resources set.
8492
type EntryResource struct {
8593
URI string `json:"uri"`
8694
Digest string `json:"digest"`
95+
// Size is the length in bytes of the file's raw content — the same
96+
// bytes the digest covers.
97+
Size int64 `json:"size"`
8798
}
8899

89100
// Entry is a skill entry as carried by skills/list and skills/get results.
101+
// The wire `resources` field is required and is either the Resources array
102+
// or the literal string "dynamic" (when Dynamic is set); an entry with
103+
// neither is invalid and hosts will not load it.
90104
type Entry struct {
91105
// URI is the resource URI of the skill's SKILL.md.
92106
URI string `json:"uri"`
93107
// Frontmatter is the SKILL.md YAML frontmatter rendered verbatim as JSON.
94108
Frontmatter map[string]any `json:"frontmatter"`
95-
// Resources enumerates every file of the skill with its digest — the unit
96-
// of content a host verifies and binds approval to. Omitted only for
97-
// dynamically generated skills that cannot publish stable digests.
98-
Resources []EntryResource `json:"resources,omitempty"`
109+
// Resources enumerates every file of the skill with its digest and size —
110+
// the unit of content a host verifies and binds approval to.
111+
Resources []EntryResource `json:"resources"`
112+
// Dynamic marks a skill whose content is generated on demand and cannot
113+
// publish stable digests. When set, `resources` serializes as the string
114+
// "dynamic" and Resources is ignored.
115+
Dynamic bool `json:"-"`
116+
}
117+
118+
// entryWire is Entry's JSON shape; resources holds an array or "dynamic".
119+
type entryWire struct {
120+
URI string `json:"uri"`
121+
Frontmatter map[string]any `json:"frontmatter"`
122+
Resources json.RawMessage `json:"resources"`
123+
}
124+
125+
func (e Entry) MarshalJSON() ([]byte, error) {
126+
w := entryWire{URI: e.URI, Frontmatter: e.Frontmatter}
127+
if e.Dynamic {
128+
w.Resources = json.RawMessage(`"dynamic"`)
129+
} else {
130+
resources := e.Resources
131+
if resources == nil {
132+
resources = []EntryResource{}
133+
}
134+
b, err := json.Marshal(resources)
135+
if err != nil {
136+
return nil, err
137+
}
138+
w.Resources = b
139+
}
140+
return json.Marshal(w)
141+
}
142+
143+
func (e *Entry) UnmarshalJSON(data []byte) error {
144+
var w entryWire
145+
if err := json.Unmarshal(data, &w); err != nil {
146+
return err
147+
}
148+
*e = Entry{URI: w.URI, Frontmatter: w.Frontmatter}
149+
if len(w.Resources) == 0 {
150+
return nil
151+
}
152+
if bytes.Equal(bytes.TrimSpace(w.Resources), []byte(`"dynamic"`)) {
153+
e.Dynamic = true
154+
return nil
155+
}
156+
return json.Unmarshal(w.Resources, &e.Resources)
99157
}
100158

101159
// Entry returns the skill's wire entry.
102160
func (s *Skill) Entry() Entry {
103161
resources := make([]EntryResource, 0, len(s.Files))
104162
for _, f := range s.Files {
105-
resources = append(resources, EntryResource{URI: s.FileURI(f.Path), Digest: f.Digest})
163+
resources = append(resources, EntryResource{
164+
URI: s.FileURI(f.Path),
165+
Digest: f.Digest,
166+
Size: int64(len(f.Content)),
167+
})
106168
}
107169
return Entry{URI: s.URI(), Frontmatter: s.Frontmatter, Resources: resources}
108170
}
@@ -150,6 +212,17 @@ func New(skillPath string, files []File) (*Skill, error) {
150212
slices.SortFunc(sorted, func(a, b File) int { return strings.Compare(a.Path, b.Path) })
151213
ordered := append([]File{*skillMD}, sorted...)
152214

215+
if len(ordered) > MaxFilesPerSkill {
216+
return nil, fmt.Errorf("skill %q: %d files exceeds the SEP-2640 limit of %d", skillPath, len(ordered), MaxFilesPerSkill)
217+
}
218+
var totalBytes int64
219+
for _, f := range ordered {
220+
totalBytes += int64(len(f.Content))
221+
}
222+
if totalBytes > MaxSkillBytes {
223+
return nil, fmt.Errorf("skill %q: %d bytes exceeds the SEP-2640 limit of %d", skillPath, totalBytes, MaxSkillBytes)
224+
}
225+
153226
fm, err := ParseFrontmatter(skillMD.Content)
154227
if err != nil {
155228
return nil, fmt.Errorf("skill %q: %w", skillPath, err)

0 commit comments

Comments
 (0)