Skip to content

Add CallToolResult<T>, CallToolAsync<T>, and OutputSchema for tools returning CallToolResult#1272

Open
Copilot wants to merge 15 commits intomainfrom
copilot/support-output-schema-independently
Open

Add CallToolResult<T>, CallToolAsync<T>, and OutputSchema for tools returning CallToolResult#1272
Copilot wants to merge 15 commits intomainfrom
copilot/support-output-schema-independently

Conversation

Copy link
Contributor

Copilot AI commented Feb 14, 2026

Tools returning CallToolResult directly (to control IsError, StructuredContent, etc.) had no way to advertise a meaningful output schema — the inferred schema would reflect CallToolResult itself rather than the actual structured content shape.

Changes

CallToolResult<T> (new type)

  • Added sealed CallToolResult<T> that derives from Result (inheriting Meta) — a peer of CallToolResult where Content is T? instead of IList<ContentBlock>
  • Also exposes bool? IsError for error signaling
  • Implements internal ICallToolResultTyped interface with ToCallToolResult(JsonSerializerOptions) that serializes T once to JsonNode, then uses ToString() for the text content block

Server-side: AIFunctionMcpServerTool

  • Recognizes CallToolResult<T> as a return type (including Task<CallToolResult<T>> and ValueTask<CallToolResult<T>>)
  • Uses T to automatically infer the OutputSchema — consolidated into the existing schema detection code alongside task unwrapping and return-type inference
  • During invocation, delegates to ICallToolResultTyped.ToCallToolResult() for serialization, propagating IsError and Meta
  • XML doc comments on McpServerTool and McpServerToolAttribute updated to document CallToolResult<T> in the return type table

Client-side: CallToolAsync<T>

  • Added CallToolAsync<T> on McpClient that calls the existing CallToolAsync, then deserializes the result as T?
  • Deserializes StructuredContent (preferred) or the first TextContentBlock as T using OfType<TextContentBlock>().FirstOrDefault()
  • Throws McpException if IsError is true — callers who need error details can use the non-generic CallToolAsync overload

McpServerToolCreateOptions.OutputSchema

  • JsonElement? OutputSchema allows supplying a pre-built schema directly via options
  • The presence of OutputSchema on the options determines whether structured output is used — UseStructuredContent was removed from McpServerToolCreateOptions
  • UseStructuredContent remains on McpServerToolAttribute; when set, DeriveOptions generates the OutputSchema from the method's return type (using T from CallToolResult<T> if applicable)
  • Explicit OutputSchema takes precedence over CallToolResult<T> inference

Precedence

McpServerToolCreateOptions.OutputSchema > CallToolResult<T> type inference > McpServerToolAttribute.UseStructuredContent return-type inference

Usage

// Via CallToolResult<T> — schema inferred from MyResponse
[McpServerTool]
public static CallToolResult<MyResponse> MyTool(string input)
{
    return new() { Content = new MyResponse { Name = "test", Score = 42 } };
}

// Via attribute — schema inferred from return type
[McpServerTool(UseStructuredContent = true)]
public static MyResponse MyTool(string input) { ... }

// Via options — supply schema directly
McpServerTool.Create(myDelegate, new McpServerToolCreateOptions
{
    OutputSchema = myJsonSchema,
});

// Client-side deserialization — returns T, throws on IsError
MyResponse? result = await client.CallToolAsync<MyResponse>("my_tool", args);

Testing

  • 8 unit tests for CallToolResult<T> in McpServerToolTests (schema generation, serialization, IsError/Meta propagation, async methods, null content, explicit override)
  • 6 integration tests in CallToolResultOfTTests (end-to-end client-server with CallToolAsync<T>, error handling, fallback to text content, schema advertisement, arguments)
  • 3 existing OutputSchema via options tests retained
Original prompt

This section details on the original issue you should resolve

<issue_title>Support specifying OutputSchema type independently of return type for tools returning CallToolResult</issue_title>
<issue_description>Is your feature request related to a problem? Please describe.
When a tool method returns CallToolResult directly (to control properties like Meta, IsError, or manually populate StructuredContent), the SDK cannot generate a meaningful OutputSchema for the tool. The output schema either reflects the shape of CallToolResult itself—which is an internal protocol type and useless to clients—or is omitted entirely.

This forces developers to choose between:

  • Returning a strongly-typed object to get a proper OutputSchema, but losing control over Meta, IsError, and other CallToolResult properties.
  • Returning CallToolResult for full response control, but with no way to advertise the actual shape of StructuredContent to clients.

Describe the solution you'd like
Allow developers to specify the output schema type independently of the return type, for example via an attribute parameter or a create option:

// Option A: Attribute-based
[McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(MyResponseType))]
public static CallToolResult my_tool(string input) { ... }



// Option B: Via McpServerToolCreateOptions
McpServerToolCreateOptions options = new()
{
    UseStructuredContent = true,
    OutputSchemaType = typeof(MyResponseType),
};

When OutputSchemaType is set, the SDK would generate the OutputSchema from that type while still allowing the method to return CallToolResult with full control over the response.

Describe alternatives you've considered

  • Returning a typed object: Works for simple cases, but precludes setting Meta, IsError, or customizing Content independently of StructuredContent.
  • Schema node transformation workaround: Creating a stub tool with McpServerTool.Create(() => new T()) solely to extract its output schema, then using SchemaCreateOptions.TransformSchemaNode on the real tool to replace the CallToolResult schema with the stub's. This works but is fragile, non-obvious, and requires significant boilerplate.
  • Manually setting StructuredContent without UseStructuredContent: Provides structured data to clients but no OutputSchema is advertised in tools/list, so clients can't validate or understand the response shape ahead of time.

Additional context
This gap is particularly impactful in scenarios where tools need to return both structured content and out-of-band metadata via CallToolResult.Meta. 
</issue_description>

<agent_instructions>Add a Type? OutputSchemaType property to McpServerToolAttribute and a JsonElement? OutputSchema property to McpServerToolCreateOptions. The OutputSchema property, if it exists, will just be used to initialize the OutputSchema on the creation options using AIJsonUtilities. This let's someone using the attribute specify a type, and someone using the options bag directly either specify a type (with a call to AIJsonUtilities to create the schema) or just supply their own custom schema. If the schema is specified, UseStructuredOutput should be forced to true in the options bag. Make sure to add good test coverage of this in the existing test files. Also ensure any existing docs about UseStructuredOutput and [McpServerTool] are updated appropriately.</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

…pServerToolCreateOptions

- Added Type? OutputSchemaType property to McpServerToolAttribute
- Added JsonElement? OutputSchema property to McpServerToolCreateOptions
- Updated DeriveOptions to generate schema from OutputSchemaType via AIJsonUtilities.CreateJsonSchema
- Updated CreateOutputSchema to use explicit OutputSchema when provided (takes precedence)
- OutputSchema forces structured content behavior even if UseStructuredContent is false
- Updated Clone() to preserve OutputSchema
- Added 8 new tests covering various scenarios
- Updated XML documentation

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copilot AI changed the title [WIP] Support specifying OutputSchema type for CallToolResult Add OutputSchemaType and OutputSchema for tools returning CallToolResult Feb 14, 2026
Copilot AI requested a review from stephentoub February 14, 2026 13:53
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…namicallyAccessedMembers

- In DeriveOptions, force UseStructuredContent=true when OutputSchemaType is set
- Simplify GetToolDescription to only check UseStructuredContent (no longer needs OutputSchema check)
- Remove unnecessary [DynamicallyAccessedMembers] from OutputSchemaType property

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copy link
Contributor

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…tests, add using for JsonDocument

- Add Debug.Assert in CreateOutputSchema to verify UseStructuredContent is true when OutputSchema is set
- Force UseStructuredContent=true in DeriveOptions when OutputSchema is set via options
- Update docs to say "to a non-null value" for UseStructuredContent auto-enable
- Add using statements for all JsonDocument.Parse calls in tests
- Rewrite attribute-based tests to use lambdas instead of reflection
- Add test for OutputSchemaType with UseStructuredContent=false to verify it's overridden
- Remove OutputSchemaTypeTools helper class (no longer needed)

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
@stephentoub stephentoub enabled auto-merge (squash) February 14, 2026 22:52
@mikekistler
Copy link
Contributor

@copilot Please give a short explanation of the feature added in this PR from the perspective of a user of the SDK.

…rverTool

Replace the ICallToolResultTyped interface (which contained serialization logic)
with a minimal ICallToolResultTypedContent interface that only exposes raw content
data. The serialization/conversion logic now lives in AIFunctionMcpServerTool's
ConvertCallToolResultOfT method where it's consumed.

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Use the presence of OutputSchema to indicate structured output. Keep
UseStructuredContent on the attribute — when set, DeriveOptions generates
the OutputSchema from the method's return type. Tests updated accordingly.

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copilot AI changed the title Add CallToolResult<T>, CallToolAsync<T>, and OutputSchema for tools returning CallToolResult Add CallToolResult&lt;T&gt;, CallToolAsync&lt;T&gt;, and OutputSchema for tools returning CallToolResult Feb 18, 2026
Restore the previous interface design where CallToolResult<T> implements
ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions) with the
serialization logic inside the generic type. Remove the data-only
ICallToolResultTypedContent interface and ConvertCallToolResultOfT helper.

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
…minimize diff

- Serialize to JsonNode once, use ToJsonString for text (avoid double serialization)
- Fix UseStructuredContent + CallToolResult<T>: use T for schema, not full return type
- Simplify pattern match: ICallToolResultTyped typed => instead of _ when
- Change GetCallToolResultContentType to accept Type instead of MethodInfo
- Revert CreateOutputSchema back to original structure (remove EnsureObjectSchema helper)

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 18, 2026

@copilot Please give a short explanation of the feature added in this PR from the perspective of a user of the SDK.

This PR adds three main features:

  1. CallToolResult<T> — a new return type for tool methods that gives you both structured content and control over IsError/Meta. The SDK infers the output schema from T:
[McpServerTool]
public static CallToolResult<MyResponse> MyTool(string input)
{
    return new() { Content = new MyResponse { Name = "test", Score = 42 } };
}
  1. CallToolAsync<T> — a client-side method that calls a tool and deserializes the result as T:
MyResponse result = await client.CallToolAsync<MyResponse>("my_tool", args);
  1. McpServerToolCreateOptions.OutputSchema — for advanced scenarios where you want to supply a custom JSON schema directly.

…tOrDefault

- CallToolAsync<T> now returns CallToolResult<T> instead of T
- No longer throws on IsError; caller can inspect IsError on the result
- Use OfType<TextContentBlock>().FirstOrDefault() instead of indexing
- Use ToString() instead of ToJsonString(serializerOptions) in ToCallToolResult
- Updated tests accordingly

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

T? content = default;

if (result.IsError is not true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot if IsError is true, are we now losing any message included in content?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go back to it returning T. Then someone who wants the details can just use the non generic overload.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 24bbac3CallToolAsync<T> now returns T? and throws McpException with the error message text when IsError is true.

/// the SDK to handle serialization of a strongly-typed result.
/// </para>
/// </remarks>
public sealed class CallToolResult<T> : ICallToolResultTyped
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot the non generic derives from Result... Can this one as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 24bbac3CallToolResult<T> now derives from Result, inheriting Meta. Removed the duplicate Meta property.

/// </summary>
private static Type? GetCallToolResultContentType(Type returnType)
{
if (returnType.IsGenericType)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot why can't all of this schema handling for CallToolResult be done where the rest of the schema detection is done? There's already code unwrapping tasks, creating return schema for the return type, etc. I'm missing why this needs to be done separately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated in 24bbac3 — unified into a single block after all attribute handling:

Type? outputSchemaType = GetCallToolResultContentType(method.ReturnType);
if (outputSchemaType is null && useStructuredContent)
{
    outputSchemaType = method.ReturnType;
}
if (outputSchemaType is not null)
{
    newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, ...);
}

GetCallToolResultContentType always runs first (handles CallToolResult<T> with or without the attribute), then UseStructuredContent fills in the return type only when CallToolResult<T> wasn't detected.

… throws on IsError, consolidate schema handling

- CallToolResult<T> now derives from Result (inherits Meta), removed duplicate Meta property
- CallToolAsync<T> returns T? instead of CallToolResult<T>, throws McpException on IsError
- Consolidated schema handling in DeriveOptions: unified UseStructuredContent and
  CallToolResult<T> auto-detection into a single block
- Updated XML docs for cref changes (Result.Meta)

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support specifying OutputSchema type independently of return type for tools returning CallToolResult

4 participants

Comments