From b1c3b16910cfa89f7d2f19e1c31b8db143938450 Mon Sep 17 00:00:00 2001 From: RossTarrant Date: Wed, 13 May 2026 11:52:06 +0100 Subject: [PATCH 1/2] Add output schema to select readonly tools --- internal/toolsnaps/toolsnaps.go | 10 +++++++ pkg/github/context_tools.go | 3 +- pkg/github/feature_flags.go | 4 +++ pkg/github/issues.go | 3 +- pkg/github/minimal_types.go | 5 ++++ pkg/github/output_schema.go | 21 +++++++++++++ pkg/github/pullrequests.go | 10 +++++-- pkg/github/repositories.go | 1 + pkg/github/search.go | 6 ++-- pkg/http/middleware/cors.go | 1 + pkg/inventory/builder.go | 14 ++++++--- pkg/inventory/registry.go | 5 +++- pkg/inventory/server_tool.go | 36 +++++++++++++++++++++++ pkg/inventory/structured_output.go | 47 ++++++++++++++++++++++++++++++ 14 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 pkg/github/output_schema.go create mode 100644 pkg/inventory/structured_output.go diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go index 4b25904ed3..77d474d955 100644 --- a/internal/toolsnaps/toolsnaps.go +++ b/internal/toolsnaps/toolsnaps.go @@ -23,6 +23,10 @@ func Test(toolName string, tool any) error { if err != nil { return fmt.Errorf("failed to marshal tool %s: %w", toolName, err) } + toolJSON, err = sortJSONKeys(toolJSON) + if err != nil { + return fmt.Errorf("failed to normalize tool %s: %w", toolName, err) + } snapPath := fmt.Sprintf("__toolsnaps__/%s.snap", toolName) @@ -97,5 +101,11 @@ func sortJSONKeys(jsonData []byte) ([]byte, error) { return nil, err } + // outputSchema is feature-gated at registration time, so default tool + // snapshots continue to represent the non-opt-in tool surface. + if object, ok := data.(map[string]any); ok { + delete(object, "outputSchema") + } + return json.MarshalIndent(data, "", " ") } diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 9f84c02118..815b2db267 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -54,7 +54,8 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, // Use json.RawMessage to ensure "properties" is included even when empty. // OpenAI strict mode requires the properties field to be present. - InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + OutputSchema: MustOutputSchema[MinimalUser](), Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 3f3d7bf976..9c2f622a53 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -3,6 +3,9 @@ package github // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// FeatureFlagOutputSchemas is the feature flag name for MCP tool output schemas. +const FeatureFlagOutputSchemas = "output_schemas" + // AllowedFeatureFlags is the allowlist of feature flags that can be enabled // by users via --features CLI flag or X-MCP-Features HTTP header. // Only flags in this list are accepted; unknown flags are silently ignored. @@ -11,6 +14,7 @@ var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular, + FeatureFlagOutputSchemas, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. diff --git a/pkg/github/issues.go b/pkg/github/issues.go index e3e1f6b223..281e53e0c0 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1432,7 +1432,8 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: MustOutputSchema[MinimalIssuesResponse](), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a8757c51c3..6b1dcc22c2 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -264,6 +264,11 @@ type MinimalPullRequest struct { Milestone string `json:"milestone,omitempty"` } +// MinimalPullRequestsResponse wraps pull requests for MCP structured output. +type MinimalPullRequestsResponse struct { + PullRequests []MinimalPullRequest `json:"pull_requests"` +} + // MinimalPRBranch is the trimmed output type for pull request branch references. type MinimalPRBranch struct { Ref string `json:"ref"` diff --git a/pkg/github/output_schema.go b/pkg/github/output_schema.go new file mode 100644 index 0000000000..ac51df9292 --- /dev/null +++ b/pkg/github/output_schema.go @@ -0,0 +1,21 @@ +package github + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" +) + +// MustOutputSchema infers an object output schema for T or panics during tool initialization. +func MustOutputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + var zero T + panic(fmt.Sprintf("failed to infer output schema for %T: %v", zero, err)) + } + if schema.Type != "object" { + var zero T + panic(fmt.Sprintf("output schema for %T must have type object, got %q", zero, schema.Type)) + } + return schema +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9c2a098755..f70fc9b2e7 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1135,7 +1135,8 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: MustOutputSchema[MinimalPullRequestsResponse](), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -1231,7 +1232,12 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + if inventory.OutputSchemasEnabled(ctx) || deps.IsFeatureEnabled(ctx, FeatureFlagOutputSchemas) { + result.StructuredContent = MinimalPullRequestsResponse{PullRequests: minimalPRs} + } + + return result, nil, nil }) } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c946d6308e..7957534220 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -53,6 +53,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"owner", "repo", "sha"}, }), + OutputSchema: MustOutputSchema[MinimalCommit](), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..1c3e8a178b 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -380,7 +380,8 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: MustOutputSchema[MinimalSearchUsersResult](), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -422,7 +423,8 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: MustOutputSchema[MinimalSearchUsersResult](), }, []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { diff --git a/pkg/http/middleware/cors.go b/pkg/http/middleware/cors.go index 2eaf4227b4..fb8e8e548d 100644 --- a/pkg/http/middleware/cors.go +++ b/pkg/http/middleware/cors.go @@ -17,6 +17,7 @@ func SetCorsHeaders(h http.Handler) http.Handler { "Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-ID", + "X-Custom-Auth-Headers", headers.AuthorizationHeader, headers.MCPReadOnlyHeader, headers.MCPToolsetsHeader, diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index d656359bb6..759c5dd55c 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -14,10 +14,16 @@ var ( ErrUnknownTools = errors.New("unknown tools specified in WithTools") ) -// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. -// This is defined here to avoid importing pkg/github (which imports pkg/inventory). -// The value must match github.MCPAppsFeatureFlag. -const mcpAppsFeatureFlag = "remote_mcp_ui_apps" +const ( + // mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. + // This is defined here to avoid importing pkg/github (which imports pkg/inventory). + // The value must match github.MCPAppsFeatureFlag. + mcpAppsFeatureFlag = "remote_mcp_ui_apps" + + // outputSchemasFeatureFlag controls MCP tool output schema metadata. + // The value must match github.FeatureFlagOutputSchemas. + outputSchemasFeatureFlag = "output_schemas" +) // ToolFilter is a function that determines if a tool should be included. // Returns true if the tool should be included, false to exclude it. diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index a0bbc7a550..f38f1ff3fa 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -182,8 +182,11 @@ func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { tools = stripMCPAppsMetadata(tools) } + registerOpts := RegisterToolOptions{ + IncludeOutputSchema: r.checkFeatureFlag(ctx, outputSchemasFeatureFlag), + } for _, tool := range tools { - tool.RegisterFunc(s, deps) + tool.RegisterFuncWithOptions(s, deps, registerOpts) } } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 752a4c2bd0..a312d13bb7 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -103,14 +103,44 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { return st.HandlerFunc(deps) } +// RegisterToolOptions controls optional tool registration behavior. +type RegisterToolOptions struct { + IncludeOutputSchema bool +} + +type outputSchemasEnabledKey struct{} + +// WithOutputSchemasEnabled marks a tool call context as output-schema aware. +func WithOutputSchemasEnabled(ctx context.Context) context.Context { + return context.WithValue(ctx, outputSchemasEnabledKey{}, true) +} + +// OutputSchemasEnabled reports whether a tool call was registered with output schemas enabled. +func OutputSchemasEnabled(ctx context.Context) bool { + enabled, _ := ctx.Value(outputSchemasEnabledKey{}).(bool) + return enabled +} + // RegisterFunc registers the tool with the server using the provided dependencies. // Icons are automatically applied from the toolset metadata if not already set. // A shallow copy of the tool is made to avoid mutating the original ServerTool. // Panics if the tool has no handler - all tools should have handlers. func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { + st.RegisterFuncWithOptions(s, deps, RegisterToolOptions{}) +} + +// RegisterFuncWithOptions registers the tool with optional feature-gated metadata. +func (st *ServerTool) RegisterFuncWithOptions(s *mcp.Server, deps any, opts RegisterToolOptions) { handler := st.Handler(deps) // This will panic if HandlerFunc is nil // Make a shallow copy of the tool to avoid mutating the original toolCopy := st.Tool + if opts.IncludeOutputSchema && toolCopy.OutputSchema != nil { + handler = wrapHandlerWithOutputSchemasEnabled(handler) + handler = wrapHandlerWithStructuredContent(handler) + } + if !opts.IncludeOutputSchema { + toolCopy.OutputSchema = nil + } // Apply icons from toolset metadata if tool doesn't have icons set if len(toolCopy.Icons) == 0 { toolCopy.Icons = st.Toolset.Icons() @@ -118,6 +148,12 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { s.AddTool(&toolCopy, handler) } +func wrapHandlerWithOutputSchemasEnabled(next mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return next(WithOutputSchemasEnabled(ctx), req) + } +} + // NewServerTool creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. // The handler function takes dependencies (as any) and returns a typed handler. // Callers should type-assert deps to their typed dependencies struct. diff --git a/pkg/inventory/structured_output.go b/pkg/inventory/structured_output.go new file mode 100644 index 0000000000..8412e45194 --- /dev/null +++ b/pkg/inventory/structured_output.go @@ -0,0 +1,47 @@ +package inventory + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func wrapHandlerWithStructuredContent(next mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := next(ctx, req) + if err != nil || result == nil || result.IsError || result.StructuredContent != nil { + return result, err + } + + structuredContent, ok, err := structuredContentFromText(result) + if err != nil { + return nil, err + } + if ok { + result.StructuredContent = structuredContent + } + + return result, nil + } +} + +func structuredContentFromText(result *mcp.CallToolResult) (json.RawMessage, bool, error) { + if len(result.Content) != 1 { + return nil, false, nil + } + + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, false, nil + } + + raw := json.RawMessage(text.Text) + var object map[string]any + if err := json.Unmarshal(raw, &object); err != nil { + return nil, false, fmt.Errorf("output schema enabled but text content is not a JSON object: %w", err) + } + + return raw, true, nil +} From acf6dbc9231288f452d824c14bfaae09c8f09815 Mon Sep 17 00:00:00 2001 From: RossTarrant Date: Wed, 13 May 2026 14:20:46 +0100 Subject: [PATCH 2/2] refactor approach --- pkg/github/context_tools.go | 34 ++++-- pkg/github/issues.go | 27 +++-- pkg/github/labels.go | 16 +-- pkg/github/minimal_types.go | 161 +++++++++++++++++++++++++++++ pkg/github/output_schema.go | 22 ++++ pkg/github/pullrequests.go | 11 +- pkg/github/repositories.go | 43 ++++---- pkg/github/search.go | 89 ++++++---------- pkg/github/search_utils.go | 9 +- pkg/inventory/server_tool.go | 37 +++---- pkg/inventory/structured_output.go | 47 --------- 11 files changed, 307 insertions(+), 189 deletions(-) delete mode 100644 pkg/inventory/structured_output.go diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 815b2db267..9a76d0ad94 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -54,8 +54,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, // Use json.RawMessage to ensure "properties" is included even when empty. // OpenAI strict mode requires the properties field to be present. - InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), - OutputSchema: MustOutputSchema[MinimalUser](), + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, @@ -105,7 +104,10 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, } - result := MarshalledTextResult(minimalUser) + result, err := structuredTextResult(ctx, deps, minimalUser, minimalUser) + if err != nil { + return nil, nil, err + } if deps.GetFlags(ctx).InsidersMode { if result.Meta == nil { result.Meta = mcp.Meta{} @@ -114,7 +116,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { } return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalUser]()) } type TeamInfo struct { @@ -128,6 +130,14 @@ type OrganizationTeams struct { Teams []TeamInfo `json:"teams"` } +type TeamsResponse struct { + Teams []OrganizationTeams `json:"teams"` +} + +type TeamMembersResponse struct { + Members []string `json:"members"` +} + func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataContext, @@ -221,9 +231,13 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { organizations = append(organizations, orgTeams) } - return MarshalledTextResult(organizations), nil, nil + result, err := structuredTextResult(ctx, deps, organizations, TeamsResponse{Teams: organizations}) + if err != nil { + return nil, nil, err + } + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[TeamsResponse]()) } func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -292,7 +306,11 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { members = append(members, string(member.Login)) } - return MarshalledTextResult(members), nil, nil + result, err := structuredTextResult(ctx, deps, members, TeamMembersResponse{Members: members}) + if err != nil { + return nil, nil, err + } + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[TeamMembersResponse]()) } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 281e53e0c0..82807f32be 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -593,13 +593,20 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil } - r, err := json.Marshal(issueTypes) + minimalIssueTypes := make([]MinimalIssueType, 0, len(issueTypes)) + for _, issueType := range issueTypes { + if issueType != nil { + minimalIssueTypes = append(minimalIssueTypes, convertToMinimalIssueType(issueType)) + } + } + + result, err := structuredTextResult(ctx, deps, issueTypes, MinimalIssueTypesResponse{IssueTypes: minimalIssueTypes}) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return result, nil, nil + }).WithOutputSchema(MustOutputSchema[MinimalIssueTypesResponse]()) } // AddIssueComment creates a tool to add a comment to an issue. @@ -978,9 +985,9 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") + result, err := searchHandler(ctx, deps, args, "issue", "failed to search issues") return result, nil, err - }) + }).WithOutputSchema(MustOutputSchema[MinimalSearchIssuesResult]()) } // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. @@ -1432,8 +1439,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), ReadOnlyHint: true, }, - InputSchema: schema, - OutputSchema: MustOutputSchema[MinimalIssuesResponse](), + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -1591,7 +1597,10 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { isPrivate = queryResult.GetIsPrivate() } - result := MarshalledTextResult(resp) + result, err := structuredTextResult(ctx, deps, resp, resp) + if err != nil { + return nil, nil, err + } if deps.GetFlags(ctx).InsidersMode { if result.Meta == nil { result.Meta = mcp.Meta{} @@ -1614,7 +1623,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { result.Meta["ifc"] = ifc.LabelListIssues(isPrivate, readers) } return result, nil, nil - }) + }).WithOutputSchema(MustOutputSchema[MinimalIssuesResponse]()) } // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0dbb622d91..8314973552 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -93,21 +93,21 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil, nil } - label := map[string]any{ - "id": fmt.Sprintf("%v", query.Repository.Label.ID), - "name": string(query.Repository.Label.Name), - "color": string(query.Repository.Label.Color), - "description": string(query.Repository.Label.Description), + label := MinimalLabel{ + ID: fmt.Sprintf("%v", query.Repository.Label.ID), + Name: string(query.Repository.Label.Name), + Color: string(query.Repository.Label.Color), + Description: string(query.Repository.Label.Description), } - out, err := json.Marshal(label) + result, err := structuredTextResult(ctx, deps, label, label) if err != nil { return nil, nil, fmt.Errorf("failed to marshal label: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalLabel]()) } // GetLabelForLabelsToolset returns the same GetLabel tool but registered in the labels toolset. diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 6b1dcc22c2..815798dc11 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -51,6 +51,22 @@ type MinimalSearchRepositoriesResult struct { Items []MinimalRepository `json:"items"` } +// MinimalCodeSearchResult is the trimmed output type for code search results. +type MinimalCodeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCodeSearchItem `json:"items"` +} + +// MinimalCodeSearchItem is the trimmed output type for code search result objects. +type MinimalCodeSearchItem struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Repository *MinimalRepository `json:"repository,omitempty"` +} + // MinimalCommitAuthor represents commit author information. type MinimalCommitAuthor struct { Name string `json:"name,omitempty"` @@ -104,6 +120,11 @@ type MinimalCommit struct { Files []MinimalCommitFile `json:"files,omitempty"` } +// MinimalCommitsResponse wraps commits for MCP structured output. +type MinimalCommitsResponse struct { + Commits []MinimalCommit `json:"commits"` +} + // MinimalRelease is the trimmed output type for release objects. type MinimalRelease struct { ID int64 `json:"id"` @@ -117,6 +138,11 @@ type MinimalRelease struct { Author *MinimalUser `json:"author,omitempty"` } +// MinimalReleasesResponse wraps releases for MCP structured output. +type MinimalReleasesResponse struct { + Releases []MinimalRelease `json:"releases"` +} + // MinimalBranch is the trimmed output type for branch objects. type MinimalBranch struct { Name string `json:"name"` @@ -124,12 +150,46 @@ type MinimalBranch struct { Protected bool `json:"protected"` } +// MinimalBranchesResponse wraps branches for MCP structured output. +type MinimalBranchesResponse struct { + Branches []MinimalBranch `json:"branches"` +} + // MinimalTag is the trimmed output type for tag objects. type MinimalTag struct { Name string `json:"name"` SHA string `json:"sha"` } +// MinimalTagsResponse wraps tags for MCP structured output. +type MinimalTagsResponse struct { + Tags []MinimalTag `json:"tags"` +} + +// MinimalIssueType is the trimmed output type for issue type objects. +type MinimalIssueType struct { + ID int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// MinimalIssueTypesResponse wraps issue types for MCP structured output. +type MinimalIssueTypesResponse struct { + IssueTypes []MinimalIssueType `json:"issue_types"` +} + +// MinimalLabel is the output type for repository labels. +type MinimalLabel struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` + Description string `json:"description,omitempty"` +} + // MinimalResponse represents a minimal response for all CRUD operations. // Success is implicit in the HTTP response status, and all other information // can be derived from the URL or fetched separately if needed. @@ -200,6 +260,13 @@ type MinimalIssuesResponse struct { PageInfo MinimalPageInfo `json:"pageInfo"` } +// MinimalSearchIssuesResult is the trimmed output type for issue and pull request search results. +type MinimalSearchIssuesResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalIssue `json:"items"` +} + // MinimalIssueComment is the trimmed output type for issue comment objects to reduce verbosity. type MinimalIssueComment struct { ID int64 `json:"id"` @@ -622,6 +689,36 @@ func convertToMinimalUser(user *github.User) *MinimalUser { } } +func convertToMinimalRepository(repo *github.Repository) MinimalRepository { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format(time.RFC3339) + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format(time.RFC3339) + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + + return minimalRepo +} + // convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { minimalCommit := MinimalCommit{ @@ -792,6 +889,70 @@ func convertToMinimalTag(tag *github.RepositoryTag) MinimalTag { return m } +func convertToMinimalIssueType(issueType *github.IssueType) MinimalIssueType { + m := MinimalIssueType{ + ID: issueType.GetID(), + NodeID: issueType.GetNodeID(), + Name: issueType.GetName(), + Description: issueType.GetDescription(), + Color: issueType.GetColor(), + } + + if issueType.CreatedAt != nil { + m.CreatedAt = issueType.CreatedAt.Format(time.RFC3339) + } + if issueType.UpdatedAt != nil { + m.UpdatedAt = issueType.UpdatedAt.Format(time.RFC3339) + } + + return m +} + +func convertToMinimalCodeSearchResult(result *github.CodeSearchResult) MinimalCodeSearchResult { + minimalResult := MinimalCodeSearchResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: make([]MinimalCodeSearchItem, 0, len(result.CodeResults)), + } + + for _, item := range result.CodeResults { + if item == nil { + continue + } + + minimalItem := MinimalCodeSearchItem{ + Name: item.GetName(), + Path: item.GetPath(), + SHA: item.GetSHA(), + HTMLURL: item.GetHTMLURL(), + } + if repo := item.GetRepository(); repo != nil { + repository := convertToMinimalRepository(repo) + minimalItem.Repository = &repository + } + + minimalResult.Items = append(minimalResult.Items, minimalItem) + } + + return minimalResult +} + +func convertToMinimalSearchIssuesResult(result *github.IssuesSearchResult) MinimalSearchIssuesResult { + minimalResult := MinimalSearchIssuesResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: make([]MinimalIssue, 0, len(result.Issues)), + } + + for _, issue := range result.Issues { + if issue != nil { + minimalResult.Items = append(minimalResult.Items, convertToMinimalIssue(issue)) + } + } + + return minimalResult +} + // MinimalCheckRun is the trimmed output type for check run objects. type MinimalCheckRun struct { ID int64 `json:"id"` diff --git a/pkg/github/output_schema.go b/pkg/github/output_schema.go index ac51df9292..562792cb55 100644 --- a/pkg/github/output_schema.go +++ b/pkg/github/output_schema.go @@ -1,9 +1,13 @@ package github import ( + "context" + "encoding/json" "fmt" + "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // MustOutputSchema infers an object output schema for T or panics during tool initialization. @@ -19,3 +23,21 @@ func MustOutputSchema[T any]() *jsonschema.Schema { } return schema } + +func outputSchemasEnabled(ctx context.Context, deps ToolDependencies) bool { + return deps.IsFeatureEnabled(ctx, FeatureFlagOutputSchemas) +} + +func structuredTextResult(ctx context.Context, deps ToolDependencies, textValue, structuredContent any) (*mcp.CallToolResult, error) { + data, err := json.Marshal(textValue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + result := utils.NewToolResultText(string(data)) + if outputSchemasEnabled(ctx, deps) { + result.StructuredContent = structuredContent + } + + return result, nil +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index f70fc9b2e7..2c76100078 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1135,8 +1135,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), ReadOnlyHint: true, }, - InputSchema: schema, - OutputSchema: MustOutputSchema[MinimalPullRequestsResponse](), + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -1233,12 +1232,12 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } result := utils.NewToolResultText(string(r)) - if inventory.OutputSchemasEnabled(ctx) || deps.IsFeatureEnabled(ctx, FeatureFlagOutputSchemas) { + if outputSchemasEnabled(ctx, deps) { result.StructuredContent = MinimalPullRequestsResponse{PullRequests: minimalPRs} } return result, nil, nil - }) + }).WithOutputSchema(MustOutputSchema[MinimalPullRequestsResponse]()) } // MergePullRequest creates a tool to merge a pull request. @@ -1407,9 +1406,9 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests") + result, err := searchHandler(ctx, deps, args, "pr", "failed to search pull requests") return result, nil, err - }) + }).WithOutputSchema(MustOutputSchema[MinimalSearchIssuesResult]()) } // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 7957534220..da1dace9d6 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -53,7 +53,6 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"owner", "repo", "sha"}, }), - OutputSchema: MustOutputSchema[MinimalCommit](), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -108,14 +107,14 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { // Convert to minimal commit minimalCommit := convertToMinimalCommit(commit, includeDiff) - r, err := json.Marshal(minimalCommit) + result, err := structuredTextResult(ctx, deps, minimalCommit, minimalCommit) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalCommit]()) } // ListCommits creates a tool to get commits of a branch in a repository. @@ -255,14 +254,14 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { minimalCommits[i] = convertToMinimalCommit(commit, false) } - r, err := json.Marshal(minimalCommits) + result, err := structuredTextResult(ctx, deps, minimalCommits, MinimalCommitsResponse{Commits: minimalCommits}) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalCommitsResponse]()) } // ListBranches creates a tool to list branches in a GitHub repository. @@ -342,14 +341,14 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) } - r, err := json.Marshal(minimalBranches) + result, err := structuredTextResult(ctx, deps, minimalBranches, MinimalBranchesResponse{Branches: minimalBranches}) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalBranchesResponse]()) } // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. @@ -1582,14 +1581,14 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { } } - r, err := json.Marshal(minimalTags) + result, err := structuredTextResult(ctx, deps, minimalTags, MinimalTagsResponse{Tags: minimalTags}) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalTagsResponse]()) } // GetTag creates a tool to get details about a specific tag in a GitHub repository. @@ -1770,14 +1769,14 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { } } - r, err := json.Marshal(minimalReleases) + result, err := structuredTextResult(ctx, deps, minimalReleases, MinimalReleasesResponse{Releases: minimalReleases}) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalReleasesResponse]()) } // GetLatestRelease creates a tool to get the latest release in a GitHub repository. @@ -1836,14 +1835,14 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get latest release", resp, body), nil, nil } - r, err := json.Marshal(release) + result, err := structuredTextResult(ctx, deps, release, convertToMinimalRelease(release)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalRelease]()) } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -1913,14 +1912,14 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get release by tag", resp, body), nil, nil } - r, err := json.Marshal(release) + result, err := structuredTextResult(ctx, deps, release, convertToMinimalRelease(release)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalRelease]()) } // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. diff --git a/pkg/github/search.go b/pkg/github/search.go index 1c3e8a178b..0cd1b46335 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -92,7 +92,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, resp, err := client.Search.Repositories(ctx, query, opts) + searchResult, resp, err := client.Search.Repositories(ctx, query, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to search repositories with query '%s'", query), @@ -110,60 +110,31 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), nil, nil } - // Return either minimal or full response based on parameter - var r []byte - if minimalOutput { - minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) - for _, repo := range result.Repositories { - minimalRepo := MinimalRepository{ - ID: repo.GetID(), - Name: repo.GetName(), - FullName: repo.GetFullName(), - Description: repo.GetDescription(), - HTMLURL: repo.GetHTMLURL(), - Language: repo.GetLanguage(), - Stars: repo.GetStargazersCount(), - Forks: repo.GetForksCount(), - OpenIssues: repo.GetOpenIssuesCount(), - Private: repo.GetPrivate(), - Fork: repo.GetFork(), - Archived: repo.GetArchived(), - DefaultBranch: repo.GetDefaultBranch(), - } - - if repo.UpdatedAt != nil { - minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.CreatedAt != nil { - minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.Topics != nil { - minimalRepo.Topics = repo.Topics - } - - minimalRepos = append(minimalRepos, minimalRepo) + minimalRepos := make([]MinimalRepository, 0, len(searchResult.Repositories)) + for _, repo := range searchResult.Repositories { + if repo != nil { + minimalRepos = append(minimalRepos, convertToMinimalRepository(repo)) } + } - minimalResult := &MinimalSearchRepositoriesResult{ - TotalCount: result.GetTotal(), - IncompleteResults: result.GetIncompleteResults(), - Items: minimalRepos, - } + minimalResult := &MinimalSearchRepositoriesResult{ + TotalCount: searchResult.GetTotal(), + IncompleteResults: searchResult.GetIncompleteResults(), + Items: minimalRepos, + } - r, err = json.Marshal(minimalResult) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal minimal response", err), nil, nil - } - } else { - r, err = json.Marshal(result) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal full response", err), nil, nil - } + textValue := any(minimalResult) + if !minimalOutput { + textValue = searchResult + } + result, err := structuredTextResult(ctx, deps, textValue, minimalResult) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return result, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalSearchRepositoriesResult]()) } // SearchCode creates a tool to search for code across GitHub repositories. @@ -251,14 +222,14 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil } - r, err := json.Marshal(result) + toolResult, err := structuredTextResult(ctx, deps, result, convertToMinimalCodeSearchResult(result)) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return toolResult, nil, nil }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalCodeSearchResult]()) } func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -340,11 +311,11 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen minimalResp.IncompleteResults = *result.IncompleteResults } - r, err := json.Marshal(minimalResp) + toolResult, err := structuredTextResult(ctx, deps, minimalResp, minimalResp) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return toolResult, nil, nil } // SearchUsers creates a tool to search for GitHub users. @@ -380,14 +351,13 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), ReadOnlyHint: true, }, - InputSchema: schema, - OutputSchema: MustOutputSchema[MinimalSearchUsersResult](), + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "user", deps, args) }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalSearchUsersResult]()) } // SearchOrgs creates a tool to search for GitHub organizations. @@ -423,12 +393,11 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), ReadOnlyHint: true, }, - InputSchema: schema, - OutputSchema: MustOutputSchema[MinimalSearchUsersResult](), + InputSchema: schema, }, []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "org", deps, args) }, - ) + ).WithOutputSchema(MustOutputSchema[MinimalSearchUsersResult]()) } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index c5502f6308..78b7985d16 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -2,7 +2,6 @@ package github import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -39,7 +38,7 @@ func hasTypeFilter(query string) bool { func searchHandler( ctx context.Context, - getClient GetClientFn, + deps ToolDependencies, args map[string]any, searchType string, errorPrefix string, @@ -90,7 +89,7 @@ func searchHandler( }, } - client, err := getClient(ctx) + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil } @@ -108,10 +107,10 @@ func searchHandler( return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } - r, err := json.Marshal(result) + toolResult, err := structuredTextResult(ctx, deps, result, convertToMinimalSearchIssuesResult(result)) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - return utils.NewToolResultText(string(r)), nil + return toolResult, nil } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index a312d13bb7..dc96ec369b 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -51,6 +51,10 @@ type ServerTool struct { // Tool is the MCP tool definition containing name, description, schema, etc. Tool mcp.Tool + // OutputSchema is attached to Tool.OutputSchema at registration time when + // the output_schemas feature is enabled. + OutputSchema any + // Toolset contains metadata about which toolset this tool belongs to. Toolset ToolsetMetadata @@ -103,24 +107,17 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { return st.HandlerFunc(deps) } +// WithOutputSchema attaches a feature-gated output schema to the tool. +func (st ServerTool) WithOutputSchema(schema any) ServerTool { + st.OutputSchema = schema + return st +} + // RegisterToolOptions controls optional tool registration behavior. type RegisterToolOptions struct { IncludeOutputSchema bool } -type outputSchemasEnabledKey struct{} - -// WithOutputSchemasEnabled marks a tool call context as output-schema aware. -func WithOutputSchemasEnabled(ctx context.Context) context.Context { - return context.WithValue(ctx, outputSchemasEnabledKey{}, true) -} - -// OutputSchemasEnabled reports whether a tool call was registered with output schemas enabled. -func OutputSchemasEnabled(ctx context.Context) bool { - enabled, _ := ctx.Value(outputSchemasEnabledKey{}).(bool) - return enabled -} - // RegisterFunc registers the tool with the server using the provided dependencies. // Icons are automatically applied from the toolset metadata if not already set. // A shallow copy of the tool is made to avoid mutating the original ServerTool. @@ -134,11 +131,9 @@ func (st *ServerTool) RegisterFuncWithOptions(s *mcp.Server, deps any, opts Regi handler := st.Handler(deps) // This will panic if HandlerFunc is nil // Make a shallow copy of the tool to avoid mutating the original toolCopy := st.Tool - if opts.IncludeOutputSchema && toolCopy.OutputSchema != nil { - handler = wrapHandlerWithOutputSchemasEnabled(handler) - handler = wrapHandlerWithStructuredContent(handler) - } - if !opts.IncludeOutputSchema { + if opts.IncludeOutputSchema && st.OutputSchema != nil { + toolCopy.OutputSchema = st.OutputSchema + } else { toolCopy.OutputSchema = nil } // Apply icons from toolset metadata if tool doesn't have icons set @@ -148,12 +143,6 @@ func (st *ServerTool) RegisterFuncWithOptions(s *mcp.Server, deps any, opts Regi s.AddTool(&toolCopy, handler) } -func wrapHandlerWithOutputSchemasEnabled(next mcp.ToolHandler) mcp.ToolHandler { - return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return next(WithOutputSchemasEnabled(ctx), req) - } -} - // NewServerTool creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. // The handler function takes dependencies (as any) and returns a typed handler. // Callers should type-assert deps to their typed dependencies struct. diff --git a/pkg/inventory/structured_output.go b/pkg/inventory/structured_output.go deleted file mode 100644 index 8412e45194..0000000000 --- a/pkg/inventory/structured_output.go +++ /dev/null @@ -1,47 +0,0 @@ -package inventory - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func wrapHandlerWithStructuredContent(next mcp.ToolHandler) mcp.ToolHandler { - return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - result, err := next(ctx, req) - if err != nil || result == nil || result.IsError || result.StructuredContent != nil { - return result, err - } - - structuredContent, ok, err := structuredContentFromText(result) - if err != nil { - return nil, err - } - if ok { - result.StructuredContent = structuredContent - } - - return result, nil - } -} - -func structuredContentFromText(result *mcp.CallToolResult) (json.RawMessage, bool, error) { - if len(result.Content) != 1 { - return nil, false, nil - } - - text, ok := result.Content[0].(*mcp.TextContent) - if !ok { - return nil, false, nil - } - - raw := json.RawMessage(text.Text) - var object map[string]any - if err := json.Unmarshal(raw, &object); err != nil { - return nil, false, fmt.Errorf("output schema enabled but text content is not a JSON object: %w", err) - } - - return raw, true, nil -}