Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,71 @@ end users. Insiders-only flags are not user-toggleable.

---

## `output_schemas`

Advertises MCP [`outputSchema`](https://modelcontextprotocol.io/specification/draft/server/tools) on tools that declare one, and returns a matching `structuredContent` alongside the existing text result. It gives clients and models a machine-readable contract for a tool's response — needed for code-execution workflows, which cannot generate typed bindings without it.

The flag does not change any tool's inventory or input schema, so it does not appear in the generated list below. It also never changes the text content a tool returns: `structuredContent` carries the exact bytes of the existing serialized-JSON text block, which is the relationship the spec already describes ("a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block").

Tools that currently declare a schema are the ones whose response shape varies by their `method` argument: `actions_get`, `actions_list`, `actions_run_trigger`, `discussion_comment_write`, `issue_dependency_read`, `issue_read`, and `pull_request_read`. Their schemas live in [`pkg/github/output_schemas/`](../pkg/github/output_schemas/).

### Interaction with the negotiated protocol version

This flag controls *rollout*. It is independent of which schema shapes are *legal* for a given client, which is decided per request from the negotiated protocol version.

Protocol revision 2025-11-25 typed `outputSchema` as a closed object shape, restricted to `type: "object"` at the root, and typed `structuredContent` as a JSON object. [SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol) lifted both in 2026-07-28: an output schema may now be any valid JSON Schema 2020-12, and structured content may be any JSON value.

So a union spanning objects and arrays — which is what `issue_read` and `pull_request_read` need — is only expressible from 2026-07-28 onward. The server handles this automatically:

| Negotiated version | Object-root schemas | Non-object-root schemas |
|--------------------|---------------------|-------------------------|
| `>= 2026-07-28` | advertised | advertised |
| older, or unknown | advertised | withheld |

Non-object `structuredContent` is withheld from older clients on the same basis. The text content is unaffected in every case, so no client loses data — older ones simply do not gain the structured channel for those tools.

Schemas use `anyOf`, never `oneOf`. `oneOf` requires exactly one branch to match, which fails on real payloads here: `actions_run_trigger` has four structurally identical branches, an empty array satisfies every array branch at once, and an `issue_read` `get` on a sub-issue also satisfies the `get_parent` branch.

---

## `structured_content_only`

Requires `output_schemas`. On its own it does nothing.

With `output_schemas` alone, a schema-bearing tool sends its payload **twice** — once as the serialized-JSON text block and once as `structuredContent`. That is what the spec asks for by default, but the reason is backwards compatibility: the text block exists so that clients which cannot read `structuredContent` can still recover the payload.

A client that negotiated 2026-07-28 is not such a client. This flag drops the redundant text block for those clients, so the payload travels once:

| Configuration | Serialized result |
|---------------|-------------------|
| no output schema | 272 bytes |
| `output_schemas` | 405 bytes (+49%) |
| `output_schemas,structured_content_only` | 254 bytes (−7%) |

(Measured by `TestStructuredContentOnlyRoughlyHalvesTheResult` on a small payload.) The result is *smaller* than having no output schema at all, because a text block embeds JSON as an escaped string — every `"` becomes `\"` — while `structuredContent` carries it raw.

The saving grows with payload size. Measured against the live GitHub API on `github/github-mcp-server`, comparing the full serialized `tools/call` result:

| Call | `output_schemas` | `+ structured_content_only` |
|------|------------------|-----------------------------|
| `pull_request_read` `get_files` | 99,990 B | 48,999 B (−51%) |
| `actions_list` `list_workflows` | 30,407 B | 15,839 B (−48%) |
| `actions_get` `get_workflow_run` | 30,229 B | 15,801 B (−48%) |
| `pull_request_read` `get_check_runs` | 10,551 B | 6,240 B (−41%) |

`content` remains present as an empty array, since the schema still requires the field.

The text block is kept, and nothing is dropped, whenever it is not genuinely redundant:

- the client negotiated anything older than 2026-07-28, or its version could not be determined
- the result is an error — error messages are never dropped
- the tool returned something other than a single JSON text block (a raw diff, logs, file contents, a CSV-converted result, or a multi-part result)
- the handler set `structuredContent` itself, so the server did not author the text/structured pairing and cannot assume they match

This is a separate opt-in rather than automatic behaviour because negotiating 2026-07-28 does not *prove* a client reads `structuredContent` — no capability advertises it, and a client that ignored it would see an empty result. Enable it where the consumer is known to read structured output; code-execution hosts are the motivating case.

---

## Tools affected by each flag

The list below is regenerated from the Go source. For each user-controllable
Expand Down
5 changes: 5 additions & 0 deletions pkg/github/__toolsnaps__/sub_issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
},
"method": {
"description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\nWrites issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n",
"enum": [
"add",
"remove",
"reprioritize"
],
"type": "string"
},
"owner": {
Expand Down
18 changes: 15 additions & 3 deletions pkg/github/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an
}
},
)
return tool
return tool.WithOutputSchema(actionsListOutputSchema)
}

// ActionsGet returns the tool and handler for getting GitHub Actions resources.
Expand Down Expand Up @@ -523,7 +523,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an
}
},
)
return tool
return tool.WithOutputSchema(actionsGetOutputSchema)
}

// ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows.
Expand Down Expand Up @@ -640,7 +640,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo
}
},
)
return tool
return tool.WithOutputSchema(actionsRunTriggerOutputSchema)
}

// ActionsGetJobLogs returns the tool and handler for getting workflow job logs.
Expand Down Expand Up @@ -802,6 +802,12 @@ func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo stri
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
// go-github decodes into a *WorkflowRun, so a 200 carrying a null body
// leaves it nil and json.Marshal would emit the literal `null`. Report the
// anomaly rather than returning a payload no caller can use.
if workflowRun == nil {
return utils.NewToolResultError("workflow run response was empty"), nil, nil
}
r, err := json.Marshal(workflowRun)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err)
Expand All @@ -815,6 +821,9 @@ func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo stri
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if workflowJob == nil {
return utils.NewToolResultError("workflow job response was empty"), nil, nil
}
r, err := json.Marshal(workflowJob)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err)
Expand Down Expand Up @@ -1007,6 +1016,9 @@ func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo
}
defer func() { _ = resp.Body.Close() }()

if usage == nil {
return utils.NewToolResultError("workflow run usage response was empty"), nil, nil
}
r, err := json.Marshal(usage)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
Expand Down
7 changes: 6 additions & 1 deletion pkg/github/csv_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult
}

result.Content = []mcp.Content{&mcp.TextContent{Text: csvText}}
result.StructuredContent = nil
// StructuredContent is deliberately preserved. content and
// structuredContent are independent channels: the CSV is the compact
// rendering for the model, while structuredContent remains the
// machine-readable result a client validates against the tool's
// outputSchema. Clearing it here would make csv_output silently disable
// output_schemas for every list_* tool.
return result
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ Options are:
default:
return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil
}
})
}).WithOutputSchema(discussionCommentWriteOutputSchema)
}

func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
Expand Down
30 changes: 30 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ const FeatureFlagIssueDependencies = "issue_dependencies"
// a redeploy.
const FeatureFlagFieldsParam = "fields_param"

// FeatureFlagOutputSchemas is the feature flag name for MCP tool output
// schemas. When enabled, tools that declare one advertise `outputSchema` and
// return a matching `structuredContent` alongside the existing text result.
// It is gated so the added tools/list payload is opt-in, and so it can be
// switched off as a kill switch without a redeploy.
//
// Note this flag controls *rollout*, not protocol legality: which schema
// shapes may be advertised to a given client is decided separately, per
// request, from the negotiated protocol version. See
// inventory.OutputSchemaVersionGate.
const FeatureFlagOutputSchemas = "output_schemas"

// FeatureFlagStructuredContentOnly drops the serialized-JSON text block from
// results whose structuredContent already carries the identical bytes, for
// clients speaking protocol 2026-07-28 or later. Without it, a schema-bearing
// tool sends the same JSON twice; with it, such a response is roughly halved.
//
// It requires FeatureFlagOutputSchemas — on its own it does nothing, because
// without a declared schema no structuredContent is produced to replace the
// text.
//
// This is a separate opt-in rather than automatic behaviour: negotiating
// 2026-07-28 does not prove a client actually reads structuredContent (there
// is no capability that advertises it), and a client that ignored it would
// see an empty result. Enable it only where the consumer is known to read
// structured output — code-execution hosts being the motivating case.
const FeatureFlagStructuredContentOnly = "structured_content_only"

// 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.
Expand All @@ -49,6 +77,8 @@ var AllowedFeatureFlags = []string{
FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
FeatureFlagFieldsParam,
FeatureFlagOutputSchemas,
FeatureFlagStructuredContentOnly,
}

// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/issue_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Options are:
}
})
st.FeatureFlagEnable = FeatureFlagIssueDependencies
return st
return st.WithOutputSchema(issueDependencyReadOutputSchema)
}

// GetIssueBlockedBy lists the issues that block the given issue.
Expand Down
12 changes: 11 additions & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
})
}).WithOutputSchema(issueReadOutputSchema)
}

func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
Expand Down Expand Up @@ -931,6 +931,15 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
subIssues = filteredSubIssues
}

// go-github declares `var subIssues []*SubIssue` and leaves it nil when the
// response body is empty or null, which json.Marshal renders as the literal
// `null` rather than `[]`. Every sibling method normalises (they build with
// make), so normalise here too: callers parsing the result expect an array,
// and null does not conform to this tool's declared output schema.
if subIssues == nil {
subIssues = []*github.SubIssue{}
}

r, err := json.Marshal(subIssues)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
Expand Down Expand Up @@ -1388,6 +1397,7 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
"- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n" +
"- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n" +
"Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n",
Enum: []any{"add", "remove", "reprioritize"},
},
"owner": {
Type: "string",
Expand Down
Loading