From 34e0eef1748ccf010418b730fdf3341c50b666fc Mon Sep 17 00:00:00 2001 From: mmporong Date: Mon, 26 Oct 2026 18:46:47 +0900 Subject: [PATCH] fix(resources): return binary application/* files from repo:// resources as blobs RepositoryResourceContentsHandler returned every application/* response as text. raw.githubusercontent.com serves binary files such as PDFs and archives as application/octet-stream, so reading them through a repo:// resource put the raw bytes into the text field, where invalid UTF-8 is replaced during JSON encoding and the content cannot be recovered. Return text only for text/* and for text-based application types (application/json, application/xml, application/javascript, and application/*+json or application/*+xml), matching how get_file_contents classifies content, and return everything else as a blob. Other top-level types keep their previous handling (for example image/svg+xml stays a blob), and a Content-Type with a malformed parameter is still classified by its media type. Assisted-by: Claude:claude-opus-5-5 --- pkg/github/repository_resource.go | 23 ++++++++++- pkg/github/repository_resource_test.go | 55 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 8141e2f0f6..bdbe1f5d22 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -213,7 +213,7 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } switch { - case strings.HasPrefix(mimeType, "text"), strings.HasPrefix(mimeType, "application"): + case isTextMIMEType(mimeType): return &mcp.ReadResourceResult{ Contents: []*mcp.ResourceContents{ { @@ -248,6 +248,27 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } } +// isTextMIMEType reports whether content of the given MIME type can be returned +// as text. Other application/* types, such as application/octet-stream which +// raw.githubusercontent.com uses for binary files, must be returned as blobs. +func isTextMIMEType(mimeType string) bool { + mediaType, _, err := mime.ParseMediaType(mimeType) + if err != nil && !errors.Is(err, mime.ErrInvalidMediaParameter) { + return false + } + if strings.HasPrefix(mediaType, "text/") { + return true + } + if !strings.HasPrefix(mediaType, "application/") { + return false + } + return mediaType == "application/json" || + mediaType == "application/xml" || + mediaType == "application/javascript" || + strings.HasSuffix(mediaType, "+json") || + strings.HasSuffix(mediaType, "+xml") +} + func escapeRepoResourceURIComponent(value string) (string, error) { if !utf8.ValidString(value) { return "", errors.New("invalid UTF-8 in repository resource URI") diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index 8ef31fda90..db4a982758 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -416,3 +416,58 @@ func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { require.Nil(t, resp) require.ErrorContains(t, err, "failed to get raw content") } + +// Test_repositoryResourceContentsHandler_ApplicationTypes verifies that binary files +// served with an application/* Content-Type (raw.githubusercontent.com serves PDFs, +// archives and other binaries as application/octet-stream) are returned as blobs +// instead of being coerced into lossy text, while text-based application types +// are still returned as text. +func Test_repositoryResourceContentsHandler_ApplicationTypes(t *testing.T) { + base, _ := url.Parse("https://raw.example.com/") + binary := []byte("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n\x00\x01\xff\xfe") + text := []byte(`{"name": "test"}`) + + tests := []struct { + contentType string + content []byte + wantBlob bool + }{ + {contentType: "application/octet-stream", content: binary, wantBlob: true}, + {contentType: "application/pdf", content: binary, wantBlob: true}, + {contentType: "application/zip", content: binary, wantBlob: true}, + {contentType: "application/json; charset=utf-8", content: text}, + {contentType: "application/xml", content: text}, + {contentType: "application/vnd.api+json", content: text}, + {contentType: "text/plain; charset", content: text}, + {contentType: "image/svg+xml", content: []byte(``), wantBlob: true}, + } + + for _, tc := range tests { + t.Run(tc.contentType, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetRawReposContentsByOwnerByRepoByPath: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", tc.contentType) + _, err := w.Write(tc.content) + require.NoError(t, err) + }), + })) + rawClient, err := raw.NewClient(client, base) + require.NoError(t, err) + ctx := ContextWithDeps(context.Background(), BaseDeps{Client: client, RawClient: rawClient}) + + resp, err := RepositoryResourceContentsHandler(repositoryResourceContentURITemplate)(ctx, &mcp.ReadResourceRequest{ + Params: &mcp.ReadResourceParams{URI: "repo://owner/repo/contents/docs/file.bin"}, + }) + require.NoError(t, err) + require.Len(t, resp.Contents, 1) + require.Equal(t, tc.contentType, resp.Contents[0].MIMEType) + if tc.wantBlob { + require.Empty(t, resp.Contents[0].Text) + require.Equal(t, tc.content, resp.Contents[0].Blob) + } else { + require.Empty(t, resp.Contents[0].Blob) + require.Equal(t, string(tc.content), resp.Contents[0].Text) + } + }) + } +}