NVIDIA / NVIDIA/NeMo-Agent-Toolkit
Request for guidance: preserving MCP schema and result semantics across toolkit adapters
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 2.6k
- Forks
- 762
- Avg merge
- 21h 28m
- Merged PRs (30d)
- 27
Description
Version
develop at 56288f68
Python 3.13.13, mcp==1.26.0, fastmcp==3.2.4, and pydantic==2.13.4. The environment was prepared from source using the standard contributor setup (uv sync --all-groups --extra most).
Installation method
Source
Describe the problem
While testing a small typed tool through the NVIDIA NeMo Agent Toolkit MCP server and client adapters, we found several places where MCP schema or result information is transformed or discarded. The observations span the nvidia_nat_mcp server, the nvidia_nat_fastmcp server, and the nvidia_nat_mcp client.
Some of these behaviors may be intentional compatibility decisions. Before opening several narrowly scoped bug reports or proposing changes, we would appreciate maintainer guidance on the intended contracts and preferred issue boundaries.
This report is independent of any application-specific integration.
Existing related work
- NVIDIA/NeMo-Agent-Toolkit#2133 and NVIDIA/NeMo-Agent-Toolkit#2134 concern preserving validation constraints in schemas exposed by the
nvidia_nat_mcpserver. - NVIDIA/NeMo-Agent-Toolkit#2129 and NVIDIA/NeMo-Agent-Toolkit#2136 concern preserving Pydantic field descriptions in schemas exposed by that server.
Those reports do not appear to cover typed result handling, MCP client result handling, local schema references, or parity with the separate nvidia_nat_fastmcp server.
Minimal model
The observations can be reproduced with a tool equivalent to the following; no external service is involved:
from pydantic import BaseModel, Field
class EchoInput(BaseModel):
message: str = Field(description="Text to return.")
class EchoOutput(BaseModel):
message: str
label_count: int
async def echo(value: EchoInput) -> EchoOutput:
return EchoOutput(message=value.message, label_count=0)
The same concerns can be demonstrated with dict results and a native MCP CallToolResult.
Observed behavior
1. MCP server adapters coerce typed results to strings
Both server converters stringify non-string workflow results before returning them to their MCP runtime:
packages/nvidia_nat_mcp/src/nat/plugins/mcp/server/tool_converter.pypackages/nvidia_nat_fastmcp/src/nat/plugins/fastmcp/server/tool_converter.py
For example, a typed result equivalent to:
{"message": "hello", "label_count": 0}
is exposed as a string result rather than as a JSON object in structuredContent. With the nvidia_nat_mcp server, the generated output schema consequently describes a string wrapper:
{
"type": "object",
"properties": {
"result": {"type": "string"}
},
"required": ["result"]
}
A native CallToolResult is also stringified, so its content blocks, structuredContent, and isError value are not preserved as protocol-level fields.
MCP's backward-compatibility guidance recommends returning structured data in structuredContent and also serializing it into a text content block, so duplication is not the concern here. The concern is that the value inside structuredContent is a JSON-encoded string instead of the original typed object. It forces clients to parse nested JSON separately, prevents discovery and validation of the original result structure, and discards native CallToolResult semantics such as structured errors.
2. The MCP client exposes only text from tool results
During discovery, MCPBaseClient.get_tools() passes inputSchema into MCPToolClient, but not outputSchema. During invocation, MCPToolClient.acall() reads text content blocks and does not expose structuredContent.
Given a compliant server response such as:
{
"content": [
{"type": "text", "text": "{\"message\":\"hello\",\"label_count\":0}"}
],
"structuredContent": {
"message": "hello",
"label_count": 0
},
"isError": false
}
the toolkit wrapper returns only the text string. For an error result, it similarly converts the failure to a formatted string, so callers cannot reliably branch on structured error fields.
We understand that the current acall() -> str contract may be deliberate. If so, guidance on how toolkit functions should consume MCP structured results would be helpful.
3. Some input-schema semantics are not preserved end to end
We found several smaller schema-conversion cases. Here, omittable means that a property is absent from JSON Schema's required array; it does not mean that the property accepts null.
| Case | Minimal input or schema | Observed behavior | Expected behavior |
|---|---|---|---|
Local $defs / $ref in an MCP input schema |
filter uses "$ref": "#/$defs/Filter", where Filter defines known properties and rejects additional properties. |
The referenced object becomes effectively unconstrained in the generated Pydantic model, so {"filter": {"unexpected": true}} is accepted. |
Resolve the local reference and validate filter against the referenced schema, rejecting the unexpected property. |
| Omittable but non-null property | query has "type": "string" and is absent from required. |
Both omission and an explicit {"query": null} are accepted. |
Accept omission and string values, but reject explicit null because the property schema does not include the null type. |
| Omitted input with a concrete default | The toolkit input model declares limit: int = 10, and the MCP caller sends {}. |
Intermediate validation can materialize limit=10 before the toolkit function is called, so the handler sees the field as explicitly supplied. |
If preserving caller-supplied field information is an intended toolkit contract, apply the default while retaining the distinction between {} and {"limit": 10}. |
default_factory in the nvidia_nat_fastmcp server |
The toolkit input model declares labels: list[str] = Field(default_factory=list). |
labels appears in the MCP input schema's required array. |
Advertise labels as omittable; when omitted, let the input model create the empty list. Explicit null should remain invalid. |
Field metadata in the nvidia_nat_fastmcp server |
A Pydantic field declares a description and constraints such as min_length=1. |
The generated MCP input schema can omit the description and constraints. | Preserve supported field descriptions and constraints in the MCP input schema. |
Sanitized parameter names in the nvidia_nat_fastmcp server |
The toolkit input schema contains a property named from, which must be sanitized internally to a valid Python parameter such as from_. |
The MCP input schema advertises from_ instead of the original property name from. |
Keep from as the protocol-facing property name and use the sanitized name only within the Python wrapper. |
The omission behavior may be a consequence of the underlying FastMCP invocation model, and optional-versus-nullable may reflect an earlier toolkit design choice. We therefore do not want to prescribe changes without confirming the intended semantics.
Show observed and expected input-schema behavior
| Reproduction | Observed | Expected |
|---|---|---|
Local $defs / $ref schema = { "type": "object", "$defs": { "Filter": { "type": "object", "properties": { "limit": {"type": "integer"} }, "additionalProperties": False, } }, "properties": { "filter": { "$ref": "#/$defs/Filter" } }, "required": ["filter"], } model = model_from_mcp_schema( "referenced", schema ) model.model_validate({ "filter": {"unexpected": True} }) |
{ "accepted": true, "value": { "filter": { "unexpected": true } } } |
{ "accepted": false, "reason": "filter contains a property not allowed by #/$defs/Filter" } |
Omittable but non-null property schema = { "type": "object", "properties": { "query": {"type": "string"} }, "required": [], } model = model_from_mcp_schema( "omittable", schema ) model.model_validate({"query": None}) |
{ "input": { "query": null }, "accepted": true, "value": { "query": null } } |
{ "input": { "query": null }, "accepted": false, "reason": "query does not permit null" } |
Omitted input with a concrete default class DefaultInput(BaseModel): limit: int = 10 manager = _SessionManager("ok") wrapper = create_mcp_wrapper( "defaulted", manager, DefaultInput ) server = SDKFastMCP("default-server") server.tool(name="defaulted")(wrapper) await server.call_tool("defaulted", {}) payload = manager.payloads[-1] payload.model_dump() payload.model_fields_set |
{ "input": {}, "payload": { "limit": 10 }, "model_fields_set": [ "limit" ] } |
{ "input": {}, "payload": { "limit": 10 }, "model_fields_set": [] } This is a candidate expectation if caller-presence information is intended to survive adapter validation. In that case, an explicit {"limit": 10} would instead produce model_fields_set: ["limit"]. |
default_factory in the nvidia_nat_fastmcp server class SchemaInput(BaseModel): model_config = ConfigDict( extra="forbid" ) query: str = Field( min_length=1, description="Text to return.", ) labels: list[str] = Field( default_factory=list ) manager = _SessionManager("ok") wrapper = create_fastmcp_wrapper( "schema_echo", manager, SchemaInput ) server = StandaloneFastMCP( "schema-server" ) server.tool(name="schema_echo")(wrapper) tool = (await server.list_tools())[0] tool.to_mcp_tool().inputSchema |
{ "type": "object", "additionalProperties": false, "properties": { "query": { "type": "string" }, "labels": { "type": "array", "items": { "type": "string" } } }, "required": [ "query", "labels" ] } |
{ "type": "object", "additionalProperties": false, "properties": { "query": { "type": "string" }, "labels": { "type": "array", "items": { "type": "string" } } }, "required": [ "query" ] } |
Field metadata in the nvidia_nat_fastmcp server class MetadataInput(BaseModel): query: str = Field( min_length=1, description="Text to return.", ) manager = _SessionManager("ok") wrapper = create_fastmcp_wrapper( "metadata", manager, MetadataInput ) server = StandaloneFastMCP( "metadata-server" ) server.tool(name="metadata")(wrapper) tool = (await server.list_tools())[0] tool.to_mcp_tool().inputSchema[ "properties" ] |
{ "query": { "type": "string" } } |
{ "query": { "type": "string", "description": "Text to return.", "minLength": 1 } } |
Sanitized parameter names in the nvidia_nat_fastmcp server schema = create_model( "KeywordInput", **{"from": (str, ...)}, ) manager = _SessionManager("ok") wrapper = create_fastmcp_wrapper( "keyword", manager, schema ) server = StandaloneFastMCP( "keyword-server" ) server.tool(name="keyword")(wrapper) tool = (await server.list_tools())[0] tool.to_mcp_tool().inputSchema |
{ "type": "object", "additionalProperties": false, "properties": { "from_": { "type": "string" } }, "required": [ "from_" ] } |
{ "type": "object", "additionalProperties": false, "properties": { "from": { "type": "string" } }, "required": [ "from" ] } |
Runnable reproducer
The following script exercises every concrete observation above without starting a network service or accessing an external system; it uses only the repository's normal development dependencies. Save it as reproduce_nat_mcp_contract_fidelity.py and run it from the repository root:
uv run python reproduce_nat_mcp_contract_fidelity.py
The script prints the current and expected behavior for every case so that one observation does not prevent the remaining cases from running. The MCP operation failed log is expected: it is emitted by the structured-error client case being reproduced.
Show the complete reproducer
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from contextlib import AbstractAsyncContextManager
from typing import Any
from fastmcp import FastMCP as StandaloneFastMCP
from mcp.server.fastmcp import FastMCP as SDKFastMCP
from mcp.types import CallToolResult
from mcp.types import ListToolsResult
from mcp.types import TextContent
from mcp.types import Tool
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from pydantic import create_model
from nat.builder.function import LambdaFunction
from nat.builder.function_info import FunctionInfo
from nat.data_models.function import FunctionBaseConfig
from nat.plugins.fastmcp.server.tool_converter import create_function_wrapper as create_fastmcp_wrapper
from nat.plugins.mcp.client.client_base import MCPBaseClient
from nat.plugins.mcp.client.client_base import MCPToolClient
from nat.plugins.mcp.server.tool_converter import create_function_wrapper as create_mcp_wrapper
from nat.plugins.mcp.server.tool_converter import register_function_with_mcp
from nat.plugins.mcp.utils import model_from_mcp_schema
class EchoInput(BaseModel):
message: str = Field(description="Text to return.")
class EchoOutput(BaseModel):
message: str
label_count: int
class SchemaInput(BaseModel):
model_config = ConfigDict(extra="forbid")
query: str = Field(min_length=1, description="Text to return.")
labels: list[str] = Field(default_factory=list)
class DefaultInput(BaseModel):
limit: int = 10
class _Runner(AbstractAsyncContextManager):
def __init__(self, result: Any):
self._result = result
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
async def result(self):
return self._result
class _SessionManager:
def __init__(self, result: Any):
self._result = result
self.payloads: list[Any] = []
def run(self, payload: Any):
self.payloads.append(payload)
return _Runner(self._result)
class _MCPParent:
server_name = "minimal-server"
def __init__(self, result: CallToolResult):
self._result = result
async def call_tool(self, name: str, arguments: dict[str, Any]):
return self._result
class _DiscoverySession:
async def list_tools(self):
return ListToolsResult(tools=[
Tool(
name="echo",
description="Echo a value.",
inputSchema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
outputSchema=EchoOutput.model_json_schema(),
)
])
class _DiscoveryClient(MCPBaseClient):
@asynccontextmanager
async def connect_to_server(self):
yield _DiscoverySession()
def show(case: str, actual: Any, expected: str) -> None:
print(f"\n[{case}]")
print("actual: " + json.dumps(actual, default=str, sort_keys=True))
print("expected: " + expected)
async def main() -> None:
# 1. Both MCP server adapters stringify typed toolkit results.
typed_result = EchoOutput(message="hello", label_count=0)
async def echo(value: EchoInput) -> EchoOutput:
return EchoOutput(message=value.message, label_count=0)
echo_function = LambdaFunction.from_info(
config=FunctionBaseConfig(),
info=FunctionInfo.create(single_fn=echo),
instance_name="echo",
)
mcp_manager = _SessionManager(typed_result)
mcp_manager.workflow = echo_function
mcp_wrapper = create_mcp_wrapper("echo", mcp_manager, EchoInput)
mcp_wrapper_result = await mcp_wrapper(message="hello")
show(
"nvidia_nat_mcp typed server result",
{"python_type": type(mcp_wrapper_result).__name__, "value": mcp_wrapper_result},
"A structured EchoOutput value, not a string.",
)
sdk_server = SDKFastMCP("minimal-server")
register_function_with_mcp(sdk_server, "echo", mcp_manager, function=echo_function)
sdk_tool = (await sdk_server.list_tools())[0]
show(
"nvidia_nat_mcp advertised output schema",
sdk_tool.outputSchema,
"The EchoOutput object schema.",
)
sdk_result = await sdk_server.call_tool("echo", {"message": "hello"})
show(
"nvidia_nat_mcp tools/call result",
{
"python_type": type(sdk_result).__name__,
"content": sdk_result[0],
"structured_content": sdk_result[1],
},
"structuredContent containing the EchoOutput object, plus backward-compatible text content.",
)
fastmcp_manager = _SessionManager(typed_result)
fastmcp_wrapper = create_fastmcp_wrapper("echo", fastmcp_manager, EchoInput)
fastmcp_wrapper_result = await fastmcp_wrapper(message="hello")
show(
"nvidia_nat_fastmcp typed server result",
{"python_type": type(fastmcp_wrapper_result).__name__, "value": fastmcp_wrapper_result},
"A structured EchoOutput value, not a string.",
)
native_error = CallToolResult(
content=[TextContent(type="text", text="invalid input")],
structuredContent={"code": "invalid_input"},
isError=True,
)
native_manager = _SessionManager(native_error)
native_wrapper = create_mcp_wrapper("native_error", native_manager, EchoInput)
native_wrapper_result = await native_wrapper(message="hello")
show(
"native CallToolResult through server adapter",
{"python_type": type(native_wrapper_result).__name__, "value": native_wrapper_result},
"A CallToolResult retaining content, structuredContent, and isError.",
)
# 2. The MCP client returns text and discards structured result fields.
successful_result = CallToolResult(
content=[TextContent(type="text", text='{"message":"hello","label_count":0}')],
structuredContent={"message": "hello", "label_count": 0},
isError=False,
)
client = MCPToolClient(
session=object(),
parent_client=_MCPParent(successful_result),
tool_name="echo",
tool_description="Echo a value.",
tool_input_schema={"type": "object", "properties": {}},
)
client_result = await client.acall({})
show(
"MCP client structured success",
{"python_type": type(client_result).__name__, "value": client_result},
"The structuredContent object, or an API that makes it available.",
)
discovery_client = _DiscoveryClient(reconnect_enabled=False)
discovery_client._session = _DiscoverySession()
discovered_tool = (await discovery_client.get_tools())["echo"]
show(
"MCP client outputSchema discovery",
{
"server_output_schema": EchoOutput.model_json_schema(),
"client_attributes": sorted(vars(discovered_tool)),
"client_has_output_schema": hasattr(discovered_tool, "output_schema"),
},
"The discovered client tool retains and exposes the server outputSchema.",
)
error_client = MCPToolClient(
session=object(),
parent_client=_MCPParent(native_error),
tool_name="fail",
tool_description="Return a structured failure.",
tool_input_schema={"type": "object", "properties": {}},
)
error_client_result = await error_client.acall({})
show(
"MCP client structured error",
{"python_type": type(error_client_result).__name__, "value": error_client_result},
"An error representation retaining structuredContent and isError.",
)
# 3a. Local references are not resolved by the MCP-schema converter.
referenced_schema = {
"type": "object",
"$defs": {
"Filter": {
"type": "object",
"properties": {"limit": {"type": "integer"}},
"additionalProperties": False,
}
},
"properties": {"filter": {"$ref": "#/$defs/Filter"}},
"required": ["filter"],
}
referenced_model = model_from_mcp_schema("referenced", referenced_schema)
referenced_value = referenced_model.model_validate({"filter": {"unexpected": True}})
show(
"local $defs/$ref",
referenced_value.model_dump(),
"ValidationError for the unexpected nested property.",
)
# 3b. An omittable string is converted into a nullable string.
omittable_schema = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": [],
}
omittable_model = model_from_mcp_schema("omittable", omittable_schema)
explicit_null = omittable_model.model_validate({"query": None})
show(
"omittable but non-null property",
explicit_null.model_dump(),
"ValidationError for explicit null; omission should remain valid.",
)
# 3c. A concrete default is materialized before the toolkit handler.
default_manager = _SessionManager("ok")
default_wrapper = create_mcp_wrapper("defaulted", default_manager, DefaultInput)
default_server = SDKFastMCP("default-server")
default_server.tool(name="defaulted")(default_wrapper)
await default_server.call_tool("defaulted", {})
default_payload = default_manager.payloads[-1]
show(
"omitted concrete default",
{
"payload": default_payload.model_dump(),
"model_fields_set": sorted(default_payload.model_fields_set),
},
'If caller-presence preservation is intended: omitted input has model_fields_set=[]; explicit {"limit":10} sets the field.',
)
# 3d-f. The standalone FastMCP frontend derives its wire schema from the
# sanitized Python signature rather than preserving all Pydantic semantics.
schema_manager = _SessionManager("ok")
schema_wrapper = create_fastmcp_wrapper("schema_echo", schema_manager, SchemaInput)
standalone_server = StandaloneFastMCP("schema-server")
standalone_server.tool(name="schema_echo")(schema_wrapper)
schema_tool = (await standalone_server.list_tools())[0].to_mcp_tool()
show(
"nvidia_nat_fastmcp default_factory and field metadata",
schema_tool.inputSchema,
"labels omitted from required; query description/minLength preserved.",
)
keyword_schema = create_model("KeywordInput", **{"from": (str, ...)})
keyword_manager = _SessionManager("ok")
keyword_wrapper = create_fastmcp_wrapper("keyword", keyword_manager, keyword_schema)
keyword_server = StandaloneFastMCP("keyword-server")
keyword_server.tool(name="keyword")(keyword_wrapper)
keyword_tool = (await keyword_server.list_tools())[0].to_mcp_tool()
show(
"nvidia_nat_fastmcp sanitized wire name",
keyword_tool.inputSchema,
'The protocol-facing property remains "from"; only the Python wrapper uses "from_".',
)
if __name__ == "__main__":
asyncio.run(main())
Expected direction
At a high level, we expected the adapters to preserve protocol-level information when the corresponding toolkit function or MCP peer provides it:
- A typed toolkit result remains structured when published through MCP.
- A native
CallToolResultretains its protocol fields. - The MCP client retains or makes available
outputSchemaandstructuredContent. - Input conversion does not broaden an omittable, non-null property into a nullable property.
- Omitted values remain distinguishable from explicitly supplied values if caller-presence information is part of the intended toolkit contract.
- The two supported MCP server frontends have documented and intentional differences, if exact schema parity is not a goal.
Backward compatibility may require retaining the existing text-oriented interfaces. An additive/raw-result API or another compatibility layer may therefore be preferable to changing existing return types directly; we would welcome maintainer guidance.
Questions for maintainers
-
Is preserving typed toolkit results an intended responsibility of both MCP server frontends?
Working assumption: Yes. When a toolkit function declares a structured output type, both frontends should use it to advertise an accurate
outputSchemaand return conformingstructuredContent. -
Should a native MCP
CallToolResultreturned by a toolkit function retain its protocol-level fields?Working assumption: Yes, provided returning protocol-native values is supported. Coercing it to text discards
content,structuredContent, andisError. Alternatively, the toolkit should explicitly reject this return type if it is outside the supported contract. -
Should
MCPToolClient.acall()remain text-only?Working assumption: The existing string API may need to remain for compatibility, but clients should have an additive way to access
outputSchema,structuredContent, and the original error semantics. -
Is schema parity between
nvidia_nat_mcpandnvidia_nat_fastmcpan explicit goal?Working assumption: The frontends may differ operationally, but equivalent toolkit functions should expose equivalent protocol-facing names, required fields, constraints, descriptions, and output types.
-
Should omittable-versus-nullable semantics be preserved?
Working assumption: Yes. A property absent from
requiredshould be omittable, but explicitnullshould be accepted only when its JSON Schema permits it. -
Should caller omission remain distinguishable from an explicitly supplied default-equivalent value?
Working assumption: This is less clear. Preserving the distinction is useful for patch and update tools, but it may conflict with existing validation and default-materialization behavior. We would appreciate guidance on whether this is part of the toolkit contract.
-
How should these findings be divided?
Working proposal: After confirming the intended contracts, separate server-result handling, client-result handling, and input-schema conversion into focused issues and regression-test changes, while using this report as their shared context.
We are happy to contribute the resulting focused regression tests and fixes once the maintainers clarify the intended contracts and preferred issue boundaries.
Relevant log output
No exception is required to reproduce these behaviors. They are visible in tools/list, tools/call, and the values delivered to the wrapped toolkit function.
Other information
The MCP specification describes structuredContent as a JSON object and recommends also returning a serialized text content block for backward compatibility:
https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with reproduce_nat_mcp_contract_fidelity.py using the documented uv command, then inspect the named tool_converter.py files and the MCPBaseClient.get_tools()/MCPToolClient.acall() entry points. Compare each current result with the stated expectations, confirm the intended contracts with maintainers, and split any agreed changes into focused reports or patches.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend-api-design
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 28/100