Skip to content

Commit 6f83796

Browse files
feat: add structured output schemas to read-only tools
Add TypedRegisterFunc to ServerTool that uses mcp.AddTool[In, Out]() for typed tool registration. The SDK auto-generates OutputSchema from the Go Out type and populates StructuredContent on tool results, while preserving existing TextContent for backwards compatibility. Tools updated with typed structured output: - get_me (MinimalUser) - list_issues (MinimalIssuesResponse) - list_pull_requests (ListPullRequestsResult) - search_issues (IssueSearchResult) - search_pull_requests (PullRequestSearchResult) - search_code (CodeSearchResult) Tools like issue_read and pull_request_read keep Out=any since they are multi-method tools returning different shapes per method. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4bded57 commit 6f83796

9 files changed

Lines changed: 271 additions & 70 deletions

File tree

pkg/github/context_tools.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool {
6161
},
6262
},
6363
nil,
64-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) {
64+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, MinimalUser, error) {
6565
client, err := deps.GetClient(ctx)
6666
if err != nil {
67-
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
67+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), MinimalUser{}, nil
6868
}
6969

7070
user, res, err := client.Users.Get(ctx, "")
@@ -73,7 +73,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool {
7373
"failed to get user",
7474
res,
7575
err,
76-
), nil, nil
76+
), MinimalUser{}, nil
7777
}
7878

7979
// Create minimal user representation instead of returning full user object
@@ -103,7 +103,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool {
103103
},
104104
}
105105

106-
return MarshalledTextResult(minimalUser), nil, nil
106+
return MarshalledTextResult(minimalUser), minimalUser, nil
107107
},
108108
)
109109
}

pkg/github/issues.go

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -961,9 +961,23 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
961961
InputSchema: schema,
962962
},
963963
[]scopes.Scope{scopes.Repo},
964-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
965-
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues")
966-
return result, nil, err
964+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, IssueSearchResult, error) {
965+
textResult, rawResult, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues")
966+
if rawResult == nil {
967+
return textResult, IssueSearchResult{}, err
968+
}
969+
issues := make([]MinimalIssue, 0, len(rawResult.Issues))
970+
for _, issue := range rawResult.Issues {
971+
if issue != nil {
972+
issues = append(issues, convertToMinimalIssue(issue))
973+
}
974+
}
975+
structured := IssueSearchResult{
976+
TotalCount: rawResult.GetTotal(),
977+
IncompleteResults: rawResult.GetIncompleteResults(),
978+
Items: issues,
979+
}
980+
return textResult, structured, nil
967981
})
968982
}
969983

@@ -1419,20 +1433,20 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
14191433
InputSchema: schema,
14201434
},
14211435
[]scopes.Scope{scopes.Repo},
1422-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1436+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, MinimalIssuesResponse, error) {
14231437
owner, err := RequiredParam[string](args, "owner")
14241438
if err != nil {
1425-
return utils.NewToolResultError(err.Error()), nil, nil
1439+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14261440
}
14271441
repo, err := RequiredParam[string](args, "repo")
14281442
if err != nil {
1429-
return utils.NewToolResultError(err.Error()), nil, nil
1443+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14301444
}
14311445

14321446
// Set optional parameters if provided
14331447
state, err := OptionalParam[string](args, "state")
14341448
if err != nil {
1435-
return utils.NewToolResultError(err.Error()), nil, nil
1449+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14361450
}
14371451

14381452
// Normalize and filter by state
@@ -1449,17 +1463,17 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
14491463
// Get labels
14501464
labels, err := OptionalStringArrayParam(args, "labels")
14511465
if err != nil {
1452-
return utils.NewToolResultError(err.Error()), nil, nil
1466+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14531467
}
14541468

14551469
orderBy, err := OptionalParam[string](args, "orderBy")
14561470
if err != nil {
1457-
return utils.NewToolResultError(err.Error()), nil, nil
1471+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14581472
}
14591473

14601474
direction, err := OptionalParam[string](args, "direction")
14611475
if err != nil {
1462-
return utils.NewToolResultError(err.Error()), nil, nil
1476+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14631477
}
14641478

14651479
// Normalize and validate orderBy
@@ -1482,7 +1496,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
14821496

14831497
since, err := OptionalParam[string](args, "since")
14841498
if err != nil {
1485-
return utils.NewToolResultError(err.Error()), nil, nil
1499+
return utils.NewToolResultError(err.Error()), MinimalIssuesResponse{}, nil
14861500
}
14871501

14881502
// There are two optional parameters: since and labels.
@@ -1491,7 +1505,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
14911505
if since != "" {
14921506
sinceTime, err = parseISOTimestamp(since)
14931507
if err != nil {
1494-
return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil
1508+
return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), MinimalIssuesResponse{}, nil
14951509
}
14961510
hasSince = true
14971511
}
@@ -1500,12 +1514,12 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15001514
// Get pagination parameters and convert to GraphQL format
15011515
pagination, err := OptionalCursorPaginationParams(args)
15021516
if err != nil {
1503-
return nil, nil, err
1517+
return nil, MinimalIssuesResponse{}, err
15041518
}
15051519

15061520
// Check if someone tried to use page-based pagination instead of cursor-based
15071521
if _, pageProvided := args["page"]; pageProvided {
1508-
return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil
1522+
return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), MinimalIssuesResponse{}, nil
15091523
}
15101524

15111525
// Check if pagination parameters were explicitly provided
@@ -1514,7 +1528,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15141528

15151529
paginationParams, err := pagination.ToGraphQLParams()
15161530
if err != nil {
1517-
return nil, nil, err
1531+
return nil, MinimalIssuesResponse{}, err
15181532
}
15191533

15201534
// Use default of 30 if pagination was not explicitly provided
@@ -1525,7 +1539,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15251539

15261540
client, err := deps.GetGQLClient(ctx)
15271541
if err != nil {
1528-
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
1542+
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), MinimalIssuesResponse{}, nil
15291543
}
15301544

15311545
vars := map[string]any{
@@ -1564,15 +1578,15 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15641578
ctx,
15651579
"failed to list issues",
15661580
err,
1567-
), nil, nil
1581+
), MinimalIssuesResponse{}, nil
15681582
}
15691583

15701584
var resp MinimalIssuesResponse
15711585
if queryResult, ok := issueQuery.(IssueQueryResult); ok {
15721586
resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment())
15731587
}
15741588

1575-
return MarshalledTextResult(resp), nil, nil
1589+
return MarshalledTextResult(resp), resp, nil
15761590
})
15771591
}
15781592

pkg/github/minimal_types.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,58 @@ type MinimalPRBranchRepo struct {
277277
Description string `json:"description,omitempty"`
278278
}
279279

280+
// ListPullRequestsResult wraps the output of list_pull_requests for structured output.
281+
type ListPullRequestsResult struct {
282+
PullRequests []MinimalPullRequest `json:"pull_requests"`
283+
}
284+
285+
// CodeSearchResult wraps the output of search_code for structured output.
286+
type CodeSearchResult struct {
287+
TotalCount int `json:"total_count"`
288+
IncompleteResults bool `json:"incomplete_results"`
289+
Items []CodeSearchResultItem `json:"items"`
290+
}
291+
292+
// CodeSearchResultItem represents a single code search result item.
293+
type CodeSearchResultItem struct {
294+
Name string `json:"name"`
295+
Path string `json:"path"`
296+
SHA string `json:"sha"`
297+
HTMLURL string `json:"html_url"`
298+
Repository *MinimalRepository `json:"repository,omitempty"`
299+
TextMatches []TextMatch `json:"text_matches,omitempty"`
300+
}
301+
302+
// TextMatch represents a text match from a code search result.
303+
type TextMatch struct {
304+
ObjectURL string `json:"object_url,omitempty"`
305+
ObjectType string `json:"object_type,omitempty"`
306+
Property string `json:"property,omitempty"`
307+
Fragment string `json:"fragment,omitempty"`
308+
Matches []Match `json:"matches,omitempty"`
309+
}
310+
311+
// Match represents an individual match within a text match fragment.
312+
type Match struct {
313+
Text string `json:"text,omitempty"`
314+
Indices []int `json:"indices,omitempty"`
315+
}
316+
317+
// IssueSearchResult wraps the output of search_issues for structured output.
318+
type IssueSearchResult struct {
319+
TotalCount int `json:"total_count"`
320+
IncompleteResults bool `json:"incomplete_results"`
321+
Items []MinimalIssue `json:"items"`
322+
}
323+
324+
// PullRequestSearchResult wraps the output of search_pull_requests for structured output.
325+
// Note: GitHub's search API returns issue-shaped results for PRs, so items use MinimalIssue.
326+
type PullRequestSearchResult struct {
327+
TotalCount int `json:"total_count"`
328+
IncompleteResults bool `json:"incomplete_results"`
329+
Items []MinimalIssue `json:"items"`
330+
}
331+
280332
type MinimalProjectStatusUpdate struct {
281333
ID string `json:"id"`
282334
Body string `json:"body,omitempty"`
@@ -883,3 +935,45 @@ func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment {
883935

884936
return m
885937
}
938+
939+
func convertToCodeSearchResult(result *github.CodeSearchResult) CodeSearchResult {
940+
items := make([]CodeSearchResultItem, 0, len(result.CodeResults))
941+
for _, cr := range result.CodeResults {
942+
item := CodeSearchResultItem{
943+
Name: cr.GetName(),
944+
Path: cr.GetPath(),
945+
SHA: cr.GetSHA(),
946+
HTMLURL: cr.GetHTMLURL(),
947+
}
948+
if repo := cr.GetRepository(); repo != nil {
949+
item.Repository = &MinimalRepository{
950+
ID: repo.GetID(),
951+
Name: repo.GetName(),
952+
FullName: repo.GetFullName(),
953+
HTMLURL: repo.GetHTMLURL(),
954+
}
955+
}
956+
for _, tm := range cr.TextMatches {
957+
textMatch := TextMatch{
958+
ObjectURL: tm.GetObjectURL(),
959+
ObjectType: tm.GetObjectType(),
960+
Property: tm.GetProperty(),
961+
Fragment: tm.GetFragment(),
962+
}
963+
for _, m := range tm.Matches {
964+
match := Match{
965+
Text: m.GetText(),
966+
Indices: m.Indices,
967+
}
968+
textMatch.Matches = append(textMatch.Matches, match)
969+
}
970+
item.TextMatches = append(item.TextMatches, textMatch)
971+
}
972+
items = append(items, item)
973+
}
974+
return CodeSearchResult{
975+
TotalCount: result.GetTotal(),
976+
IncompleteResults: result.GetIncompleteResults(),
977+
Items: items,
978+
}
979+
}

pkg/github/pullrequests.go

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,38 +1138,38 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
11381138
InputSchema: schema,
11391139
},
11401140
[]scopes.Scope{scopes.Repo},
1141-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1141+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, ListPullRequestsResult, error) {
11421142
owner, err := RequiredParam[string](args, "owner")
11431143
if err != nil {
1144-
return utils.NewToolResultError(err.Error()), nil, nil
1144+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11451145
}
11461146
repo, err := RequiredParam[string](args, "repo")
11471147
if err != nil {
1148-
return utils.NewToolResultError(err.Error()), nil, nil
1148+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11491149
}
11501150
state, err := OptionalParam[string](args, "state")
11511151
if err != nil {
1152-
return utils.NewToolResultError(err.Error()), nil, nil
1152+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11531153
}
11541154
head, err := OptionalParam[string](args, "head")
11551155
if err != nil {
1156-
return utils.NewToolResultError(err.Error()), nil, nil
1156+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11571157
}
11581158
base, err := OptionalParam[string](args, "base")
11591159
if err != nil {
1160-
return utils.NewToolResultError(err.Error()), nil, nil
1160+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11611161
}
11621162
sort, err := OptionalParam[string](args, "sort")
11631163
if err != nil {
1164-
return utils.NewToolResultError(err.Error()), nil, nil
1164+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11651165
}
11661166
direction, err := OptionalParam[string](args, "direction")
11671167
if err != nil {
1168-
return utils.NewToolResultError(err.Error()), nil, nil
1168+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11691169
}
11701170
pagination, err := OptionalPaginationParams(args)
11711171
if err != nil {
1172-
return utils.NewToolResultError(err.Error()), nil, nil
1172+
return utils.NewToolResultError(err.Error()), ListPullRequestsResult{}, nil
11731173
}
11741174

11751175
opts := &github.PullRequestListOptions{
@@ -1186,24 +1186,24 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
11861186

11871187
client, err := deps.GetClient(ctx)
11881188
if err != nil {
1189-
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
1189+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), ListPullRequestsResult{}, nil
11901190
}
11911191
prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts)
11921192
if err != nil {
11931193
return ghErrors.NewGitHubAPIErrorResponse(ctx,
11941194
"failed to list pull requests",
11951195
resp,
11961196
err,
1197-
), nil, nil
1197+
), ListPullRequestsResult{}, nil
11981198
}
11991199
defer func() { _ = resp.Body.Close() }()
12001200

12011201
if resp.StatusCode != http.StatusOK {
12021202
bodyBytes, err := io.ReadAll(resp.Body)
12031203
if err != nil {
1204-
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
1204+
return utils.NewToolResultErrorFromErr("failed to read response body", err), ListPullRequestsResult{}, nil
12051205
}
1206-
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list pull requests", resp, bodyBytes), nil, nil
1206+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list pull requests", resp, bodyBytes), ListPullRequestsResult{}, nil
12071207
}
12081208

12091209
// sanitize title/body on each PR
@@ -1226,12 +1226,8 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
12261226
}
12271227
}
12281228

1229-
r, err := json.Marshal(minimalPRs)
1230-
if err != nil {
1231-
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
1232-
}
1233-
1234-
return utils.NewToolResultText(string(r)), nil, nil
1229+
result := ListPullRequestsResult{PullRequests: minimalPRs}
1230+
return MarshalledTextResult(result), result, nil
12351231
})
12361232
}
12371233

@@ -1400,9 +1396,23 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo
14001396
InputSchema: schema,
14011397
},
14021398
[]scopes.Scope{scopes.Repo},
1403-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1404-
result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests")
1405-
return result, nil, err
1399+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, PullRequestSearchResult, error) {
1400+
textResult, rawResult, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests")
1401+
if rawResult == nil {
1402+
return textResult, PullRequestSearchResult{}, err
1403+
}
1404+
issues := make([]MinimalIssue, 0, len(rawResult.Issues))
1405+
for _, issue := range rawResult.Issues {
1406+
if issue != nil {
1407+
issues = append(issues, convertToMinimalIssue(issue))
1408+
}
1409+
}
1410+
structured := PullRequestSearchResult{
1411+
TotalCount: rawResult.GetTotal(),
1412+
IncompleteResults: rawResult.GetIncompleteResults(),
1413+
Items: issues,
1414+
}
1415+
return textResult, structured, nil
14061416
})
14071417
}
14081418

0 commit comments

Comments
 (0)