-
Notifications
You must be signed in to change notification settings - Fork 545
Audit protocol types #892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Audit protocol types #892
Conversation
/// </remarks> | ||
[JsonPropertyName("maxTokens")] | ||
public int? MaxTokens { get; init; } | ||
public required int MaxTokens { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This property was an int?
despite the schema declaring it as required. Maybe this was intentional. Happy to change back if so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should use the schema as our source of truth when defining these model types.
internal JsonRpcRequest WithId(RequestId id) | ||
{ | ||
return new JsonRpcRequest | ||
{ | ||
JsonRpc = JsonRpc, | ||
Id = id, | ||
Method = Method, | ||
Params = Params, | ||
Context = Context, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure whether this method was a workaround for the fact that the inherited Id
property was init
-only, or if the type was intentionally designed to be immutable. If the latter, maybe we need to reevaluate the decision to standardize on set
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added this in #330. At the time I was changing the JsonRpcMessage type hierarchy from interfaces and records to be class-based so I could flow the RelatedTransport. I think my logic at the time was that the records and interfaces that implemented IJsonRpcMessage previously were mostly get or init only with the notable exception of JsonRpcRequest.Id, so I changed it to also be init-only for consistency.
Prior to that, we mutated JsonRpcRequest.Id just like this PR does. https://github.com/modelcontextprotocol/csharp-sdk/pull/330/files#diff-3905e6494bc7bc14c472a02121cd4c7f0e054380966f081731bc55a99f84da58
However, even though that wasn't the reason for my change, I agree with @eiriktsarpalis that we should preserve the cloning behavior. I should have added some sort of regression test then. If we didn't clone, I'd worry that it could be a footgun for someone repeatedly sending a JsonRpcRequest using SendRequestAsync.
{ | ||
Messages = samplingMessages, | ||
MaxTokens = options?.MaxOutputTokens, | ||
MaxTokens = options?.MaxOutputTokens ?? int.MaxValue, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should determine whether int.MaxValue
is actually a reasonable default value for MaxTokens
. Previously, we allowed null
as a valid value, but the schema indicates that it's required.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not a reasonable value. LLM apis will fail using maxint.
{ | ||
Blob = dataContent.Base64Data.ToString(), | ||
MimeType = dataContent.MediaType, | ||
Uri = string.Empty, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was implicitly string.Empty
before, but now it's explicit. This PR doesn't introduce a behavioral change here, but we should decide whether the empty string should really be used here, as it's not actually a valid URI. It would also be strange to use dataContent.Uri
, because then the data is specified in two places (once in Blob
and once in Uri
).
if (request.Id.Id is null) | ||
{ | ||
request = request.WithId(new RequestId(Interlocked.Increment(ref _lastRequestId))); | ||
request.Id = new RequestId(Interlocked.Increment(ref _lastRequestId)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now mutates the request
parameter directly instead of cloning it. I think that's fine, but please correct me if not. This may have just been a workaround for JsonRpcRequest
inheriting init
-only properties, but I'm not sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to avoid potential regressions in the scope of this change, I would suggest preserving the original cloning semantics even if done manually.
/// </summary> | ||
[JsonPropertyName("name")] | ||
public string Name { get; set; } = string.Empty; | ||
public required string Name { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW introducing required
is also considered a breaking change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. And the same is true for other changes made in this PR (e.g., IReadOnlyList<T>
-> IList<T>
). We'll have to decide which changes are worth their induced breaks.
For context, the presence of |
/// </summary> | ||
[JsonPropertyName("messages")] | ||
public required IReadOnlyList<SamplingMessage> Messages { get; init; } | ||
public IList<SamplingMessage> Messages { get; set; } = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why was this changed back to optional? Presumably not required by the schema?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My thinking here was that this property is still effectively "required" in that it's not declared as allowing null
values. We just no longer require an explicit value on initialization, since a reasonable, non-null default exists ([]
).
However, your comment made me realize that removing required
actually does make the property "optional" in that we will no longer throw an exception on deserialization if the property is missing from the JSON payload. Maybe this is OK. We also don't appear to be using RespectNullableAnnotations
, so the JSON input could still explicitly provide null
for this property anyway. So, required
alone still isn't enough to enforce that the JSON property has a specified, non-null value.
Curious about your thoughts - do you think it's fine to have a default initial value as a substitute for required
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or maybe we should consider enabling RespectNullableAnnotations
so that we're able to enforce required-ness to a more complete degree?
{ | ||
Assert.Same(mockServer.Object, server); | ||
return new ReadResourceResult { Contents = [new TextResourceContents { Text = "hello" }] }; | ||
return new ReadResourceResult { Contents = [new TextResourceContents { Text = "hello", Uri = string.Empty }] }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a potentially naive question directed to the wider audience of this PR. In general, why do we prefer using string.Empty
over ""
? They both reference the same instance anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't. It's purely style which is chosen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why don't we go for the shorter and easier to read alternative then? :-)
What are our principles around this? I know JsonNode is mutable and JsonElement is not, but why is JsonNode use for params and results and JsonElement or |
Summary
Audits and standardizes MCP protocol types for consistency.
Fixes #519
Description
There are several inconsistencies across protocol types, including:
init
vs.set
required
vs. default property valuesenum
vs.string
JsonNode
vs.JsonElement
vs.IDictionary<string, JsonElement>
vs.IDictionary<string, object>
This PR applies the following set of conventions to improve consistency:
Property Mutability
Guideline: Always use
set
- noinit
-only properties.Rationale:
set
orinit
, it's easier to standardize onset
because doing so is non-breakinginit
means that if we do want to mutate a property after initialization, we need to clone the its containing object, which can be error-prone and isn't great for perfRequired Properties
Guideline: Use
required
when the spec indicates a property is required, unless a clear, safe default exists.Rationale:
Safe defaults include:
Avoid defaults such as:
""
) - this is rarely the expected value for a "required" propertyCollections
Guideline: Prefer
IList<T>
/IDictionary<TKey, TValue>
overIReadOnlyList<T>
/IReadOnlyDictionary<TKey, TValue>
.Rationale:
Open Questions
The following inconsistencies remain for further discussion:
enum
vs.string
: propertiesThe MCP schema defines some properties as unions of string values.
enum
offers strong typingstring
is more future-proof (e.g., if new values are added)Existing examples of each:
enum
:ContextInclusion
,Role
string
:ElicitResult.Action
JSON-like Structures:
The use of
JsonNode
,JsonElement
,IDictionary<string, JsonElement>
, andIDictionary<string, object>
varies.Additional Notes