Skip to content

Commit 3b052a8

Browse files
Merge branch 'main' into shopify-setup-oxygen-workflow-sc4a
2 parents e14bc39 + 304f29a commit 3b052a8

File tree

9 files changed

+444
-3
lines changed

9 files changed

+444
-3
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ bin/
1717
.DS_Store
1818

1919
# binary
20-
github-mcp-server
20+
github-mcp-server
21+
22+
.history

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,9 @@ The following sets of tools are available:
611611
- `filename`: Filename for simple single-file gist creation (string, required)
612612
- `public`: Whether the gist is public (boolean, optional)
613613

614+
- **get_gist** - Get Gist Content
615+
- `gist_id`: The ID of the gist (string, required)
616+
614617
- **list_gists** - List Gists
615618
- `page`: Page number for pagination (min 1) (number, optional)
616619
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)

pkg/github/gists.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,53 @@ func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (too
8989
}
9090
}
9191

92+
// GetGist creates a tool to get the content of a gist
93+
func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
94+
return mcp.NewTool("get_gist",
95+
mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")),
96+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
97+
Title: t("TOOL_GET_GIST", "Get Gist Content"),
98+
ReadOnlyHint: ToBoolPtr(true),
99+
}),
100+
mcp.WithString("gist_id",
101+
mcp.Required(),
102+
mcp.Description("The ID of the gist"),
103+
),
104+
),
105+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
106+
gistID, err := RequiredParam[string](request, "gist_id")
107+
if err != nil {
108+
return mcp.NewToolResultError(err.Error()), nil
109+
}
110+
111+
client, err := getClient(ctx)
112+
if err != nil {
113+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
114+
}
115+
116+
gist, resp, err := client.Gists.Get(ctx, gistID)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to get gist: %w", err)
119+
}
120+
defer func() { _ = resp.Body.Close() }()
121+
122+
if resp.StatusCode != http.StatusOK {
123+
body, err := io.ReadAll(resp.Body)
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to read response body: %w", err)
126+
}
127+
return mcp.NewToolResultError(fmt.Sprintf("failed to get gist: %s", string(body))), nil
128+
}
129+
130+
r, err := json.Marshal(gist)
131+
if err != nil {
132+
return nil, fmt.Errorf("failed to marshal response: %w", err)
133+
}
134+
135+
return mcp.NewToolResultText(string(r)), nil
136+
}
137+
}
138+
92139
// CreateGist creates a tool to create a new gist
93140
func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
94141
return mcp.NewTool("create_gist",

pkg/github/gists_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,115 @@ func Test_ListGists(t *testing.T) {
192192
}
193193
}
194194

195+
func Test_GetGist(t *testing.T) {
196+
// Verify tool definition
197+
mockClient := github.NewClient(nil)
198+
tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper)
199+
200+
assert.Equal(t, "get_gist", tool.Name)
201+
assert.NotEmpty(t, tool.Description)
202+
assert.Contains(t, tool.InputSchema.Properties, "gist_id")
203+
204+
assert.Contains(t, tool.InputSchema.Required, "gist_id")
205+
206+
// Setup mock gist for success case
207+
mockGist := github.Gist{
208+
ID: github.Ptr("gist1"),
209+
Description: github.Ptr("First Gist"),
210+
HTMLURL: github.Ptr("https://gist.github.com/user/gist1"),
211+
Public: github.Ptr(true),
212+
CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
213+
Owner: &github.User{Login: github.Ptr("user")},
214+
Files: map[github.GistFilename]github.GistFile{
215+
github.GistFilename("file1.txt"): {
216+
Filename: github.Ptr("file1.txt"),
217+
Content: github.Ptr("content of file 1"),
218+
},
219+
},
220+
}
221+
222+
tests := []struct {
223+
name string
224+
mockedClient *http.Client
225+
requestArgs map[string]interface{}
226+
expectError bool
227+
expectedGists github.Gist
228+
expectedErrMsg string
229+
}{
230+
{
231+
name: "Successful fetching different gist",
232+
mockedClient: mock.NewMockedHTTPClient(
233+
mock.WithRequestMatchHandler(
234+
mock.GetGistsByGistId,
235+
mockResponse(t, http.StatusOK, mockGist),
236+
),
237+
),
238+
requestArgs: map[string]interface{}{
239+
"gist_id": "gist1",
240+
},
241+
expectError: false,
242+
expectedGists: mockGist,
243+
},
244+
{
245+
name: "gist_id parameter missing",
246+
mockedClient: mock.NewMockedHTTPClient(
247+
mock.WithRequestMatchHandler(
248+
mock.GetGistsByGistId,
249+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
250+
w.WriteHeader(http.StatusUnprocessableEntity)
251+
_, _ = w.Write([]byte(`{"message": "Invalid Request"}`))
252+
}),
253+
),
254+
),
255+
requestArgs: map[string]interface{}{},
256+
expectError: true,
257+
expectedErrMsg: "missing required parameter: gist_id",
258+
},
259+
}
260+
261+
for _, tc := range tests {
262+
t.Run(tc.name, func(t *testing.T) {
263+
// Setup client with mock
264+
client := github.NewClient(tc.mockedClient)
265+
_, handler := GetGist(stubGetClientFn(client), translations.NullTranslationHelper)
266+
267+
// Create call request
268+
request := createMCPRequest(tc.requestArgs)
269+
270+
// Call handler
271+
result, err := handler(context.Background(), request)
272+
273+
// Verify results
274+
if tc.expectError {
275+
if err != nil {
276+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
277+
} else {
278+
// For errors returned as part of the result, not as an error
279+
assert.NotNil(t, result)
280+
textContent := getTextResult(t, result)
281+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
282+
}
283+
return
284+
}
285+
286+
require.NoError(t, err)
287+
288+
// Parse the result and get the text content if no error
289+
textContent := getTextResult(t, result)
290+
291+
// Unmarshal and verify the result
292+
var returnedGists github.Gist
293+
err = json.Unmarshal([]byte(textContent.Text), &returnedGists)
294+
require.NoError(t, err)
295+
296+
assert.Equal(t, *tc.expectedGists.ID, *returnedGists.ID)
297+
assert.Equal(t, *tc.expectedGists.Description, *returnedGists.Description)
298+
assert.Equal(t, *tc.expectedGists.HTMLURL, *returnedGists.HTMLURL)
299+
assert.Equal(t, *tc.expectedGists.Public, *returnedGists.Public)
300+
})
301+
}
302+
}
303+
195304
func Test_CreateGist(t *testing.T) {
196305
// Verify tool definition
197306
mockClient := github.NewClient(nil)

pkg/github/issues.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
ghErrors "github.com/github/github-mcp-server/pkg/errors"
13+
"github.com/github/github-mcp-server/pkg/sanitize"
1314
"github.com/github/github-mcp-server/pkg/translations"
1415
"github.com/go-viper/mapstructure/v2"
1516
"github.com/google/go-github/v76/github"
@@ -211,15 +212,15 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue {
211212

212213
return &github.Issue{
213214
Number: github.Ptr(int(fragment.Number)),
214-
Title: github.Ptr(string(fragment.Title)),
215+
Title: github.Ptr(sanitize.FilterInvisibleCharacters(string(fragment.Title))),
215216
CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
216217
UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
217218
User: &github.User{
218219
Login: github.Ptr(string(fragment.Author.Login)),
219220
},
220221
State: github.Ptr(string(fragment.State)),
221222
ID: github.Ptr(fragment.DatabaseID),
222-
Body: github.Ptr(string(fragment.Body)),
223+
Body: github.Ptr(sanitize.FilterInvisibleCharacters(string(fragment.Body))),
223224
Labels: foundLabels,
224225
Comments: github.Ptr(int(fragment.Comments.TotalCount)),
225226
}
@@ -323,6 +324,16 @@ func GetIssue(ctx context.Context, client *github.Client, owner string, repo str
323324
return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil
324325
}
325326

327+
// Sanitize title/body on response
328+
if issue != nil {
329+
if issue.Title != nil {
330+
issue.Title = github.Ptr(sanitize.FilterInvisibleCharacters(*issue.Title))
331+
}
332+
if issue.Body != nil {
333+
issue.Body = github.Ptr(sanitize.FilterInvisibleCharacters(*issue.Body))
334+
}
335+
}
336+
326337
r, err := json.Marshal(issue)
327338
if err != nil {
328339
return nil, fmt.Errorf("failed to marshal issue: %w", err)

pkg/github/pullrequests.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/shurcooL/githubv4"
1515

1616
ghErrors "github.com/github/github-mcp-server/pkg/errors"
17+
"github.com/github/github-mcp-server/pkg/sanitize"
1718
"github.com/github/github-mcp-server/pkg/translations"
1819
)
1920

@@ -123,6 +124,16 @@ func GetPullRequest(ctx context.Context, client *github.Client, owner, repo stri
123124
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil
124125
}
125126

127+
// sanitize title/body on response
128+
if pr != nil {
129+
if pr.Title != nil {
130+
pr.Title = github.Ptr(sanitize.FilterInvisibleCharacters(*pr.Title))
131+
}
132+
if pr.Body != nil {
133+
pr.Body = github.Ptr(sanitize.FilterInvisibleCharacters(*pr.Body))
134+
}
135+
}
136+
126137
r, err := json.Marshal(pr)
127138
if err != nil {
128139
return nil, fmt.Errorf("failed to marshal response: %w", err)
@@ -804,6 +815,19 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
804815
return mcp.NewToolResultError(fmt.Sprintf("failed to list pull requests: %s", string(body))), nil
805816
}
806817

818+
// sanitize title/body on each PR
819+
for _, pr := range prs {
820+
if pr == nil {
821+
continue
822+
}
823+
if pr.Title != nil {
824+
pr.Title = github.Ptr(sanitize.FilterInvisibleCharacters(*pr.Title))
825+
}
826+
if pr.Body != nil {
827+
pr.Body = github.Ptr(sanitize.FilterInvisibleCharacters(*pr.Body))
828+
}
829+
}
830+
807831
r, err := json.Marshal(prs)
808832
if err != nil {
809833
return nil, fmt.Errorf("failed to marshal response: %w", err)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
308308
gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description).
309309
AddReadTools(
310310
toolsets.NewServerTool(ListGists(getClient, t)),
311+
toolsets.NewServerTool(GetGist(getClient, t)),
311312
).
312313
AddWriteTools(
313314
toolsets.NewServerTool(CreateGist(getClient, t)),

pkg/sanitize/sanitize.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package sanitize
2+
3+
// FilterInvisibleCharacters removes invisible or control characters that should not appear
4+
// in user-facing titles or bodies. This includes:
5+
// - Unicode tag characters: U+E0001, U+E0020–U+E007F
6+
// - BiDi control characters: U+202A–U+202E, U+2066–U+2069
7+
// - Hidden modifier characters: U+200B, U+200C, U+200E, U+200F, U+00AD, U+FEFF, U+180E, U+2060–U+2064
8+
func FilterInvisibleCharacters(input string) string {
9+
if input == "" {
10+
return input
11+
}
12+
13+
// Filter runes
14+
out := make([]rune, 0, len(input))
15+
for _, r := range input {
16+
if !shouldRemoveRune(r) {
17+
out = append(out, r)
18+
}
19+
}
20+
return string(out)
21+
}
22+
23+
func shouldRemoveRune(r rune) bool {
24+
switch r {
25+
case 0x200B, // ZERO WIDTH SPACE
26+
0x200C, // ZERO WIDTH NON-JOINER
27+
0x200E, // LEFT-TO-RIGHT MARK
28+
0x200F, // RIGHT-TO-LEFT MARK
29+
0x00AD, // SOFT HYPHEN
30+
0xFEFF, // ZERO WIDTH NO-BREAK SPACE
31+
0x180E: // MONGOLIAN VOWEL SEPARATOR
32+
return true
33+
case 0xE0001: // TAG
34+
return true
35+
}
36+
37+
// Ranges
38+
// Unicode tags: U+E0020–U+E007F
39+
if r >= 0xE0020 && r <= 0xE007F {
40+
return true
41+
}
42+
// BiDi controls: U+202A–U+202E
43+
if r >= 0x202A && r <= 0x202E {
44+
return true
45+
}
46+
// BiDi isolates: U+2066–U+2069
47+
if r >= 0x2066 && r <= 0x2069 {
48+
return true
49+
}
50+
// Hidden modifiers: U+2060–U+2064
51+
if r >= 0x2060 && r <= 0x2064 {
52+
return true
53+
}
54+
55+
return false
56+
}

0 commit comments

Comments
 (0)