-
Notifications
You must be signed in to change notification settings - Fork 720
Expand file tree
/
Copy pathMcpHeaderExtractor.cs
More file actions
404 lines (350 loc) · 15 KB
/
Copy pathMcpHeaderExtractor.cs
File metadata and controls
404 lines (350 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
using System.Net.Http.Headers;
using System.Globalization;
using System.Text.Json;
#if NET
using System.Buffers;
#endif
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Client;
/// <summary>
/// Extracts parameter values from tool call arguments and adds them as HTTP headers
/// based on <c>x-mcp-header</c> schema extensions.
/// </summary>
internal static class McpHeaderExtractor
{
private const string XMcpHeaderProperty = "x-mcp-header";
/// <summary>
/// Adds custom parameter headers to an HTTP request based on a tool's schema extensions.
/// </summary>
/// <param name="headers">The HTTP request headers to add to.</param>
/// <param name="tool">The tool definition containing the input schema with x-mcp-header annotations.</param>
/// <param name="arguments">The arguments being passed to the tool call.</param>
public static void AddParameterHeaders(
HttpRequestHeaders headers,
Tool tool,
JsonElement? arguments)
{
if (!arguments.HasValue || arguments.Value.ValueKind != JsonValueKind.Object)
{
return;
}
if (tool.InputSchema.ValueKind != JsonValueKind.Object ||
!tool.InputSchema.TryGetProperty("properties", out var properties) ||
properties.ValueKind != JsonValueKind.Object)
{
return;
}
AddParameterHeadersFromProperties(headers, properties, arguments.Value);
}
/// <summary>
/// Recursively extracts parameter values from properties at any nesting depth
/// and adds them as HTTP headers.
/// </summary>
private static void AddParameterHeadersFromProperties(
HttpRequestHeaders headers,
JsonElement properties,
JsonElement arguments)
{
foreach (var property in properties.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
// Recurse into nested object properties
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
nestedProperties.ValueKind == JsonValueKind.Object &&
arguments.TryGetProperty(property.Name, out var nestedArgs) &&
nestedArgs.ValueKind == JsonValueKind.Object)
{
AddParameterHeadersFromProperties(headers, nestedProperties, nestedArgs);
}
if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
{
continue;
}
var headerName = headerNameElement.GetString();
if (string.IsNullOrEmpty(headerName))
{
continue;
}
// Look for the corresponding argument value
if (!arguments.TryGetProperty(property.Name, out var argValue))
{
continue;
}
// Null values → omit header per SEP
if (argValue.ValueKind == JsonValueKind.Null)
{
continue;
}
var headerValue = ConvertArgumentToHeaderValue(property.Value, property.Name, argValue);
if (headerValue is not null)
{
headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue);
}
}
}
// The maximum magnitude for an integer that can be represented exactly by an IEEE 754
// double-precision value (2^53 - 1). Per SEP-2243 integer x-mcp-header values MUST be within
// the JavaScript safe integer range (-2^53+1 to 2^53-1) so intermediaries can compare them.
private const long MaxSafeInteger = 9007199254740991L;
/// <summary>
/// Converts an argument value to its encoded header representation. When the property schema
/// declares an <c>integer</c> type, the value is canonicalized to its decimal string form
/// (e.g. a body value of <c>42.0</c> is emitted as <c>"42"</c>) per SEP-2243.
/// </summary>
private static string? ConvertArgumentToHeaderValue(JsonElement propertySchema, string propertyName, JsonElement argValue)
{
if (argValue.ValueKind == JsonValueKind.Number && SchemaTypeIsInteger(propertySchema))
{
if (!TryGetCanonicalSafeInteger(argValue, out long canonical))
{
throw new McpException(
$"The value '{argValue.GetRawText()}' for parameter '{propertyName}' annotated with x-mcp-header " +
$"is not a whole number within the JavaScript safe integer range (-{MaxSafeInteger} to {MaxSafeInteger}).");
}
return McpHeaderEncoder.EncodeValue(canonical);
}
return McpHeaderEncoder.ConvertToHeaderValue(argValue);
}
/// <summary>
/// Determines whether the property schema's <c>type</c> keyword declares an <c>integer</c> type,
/// either directly or as a member of a JSON Schema union array (e.g. <c>["integer", "null"]</c>).
/// </summary>
private static bool SchemaTypeIsInteger(JsonElement propertySchema)
{
if (!propertySchema.TryGetProperty("type", out var typeElement))
{
return false;
}
switch (typeElement.ValueKind)
{
case JsonValueKind.String:
return typeElement.ValueEquals("integer");
case JsonValueKind.Array:
foreach (var entry in typeElement.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String && entry.ValueEquals("integer"))
{
return true;
}
}
return false;
default:
return false;
}
}
/// <summary>
/// Attempts to interpret a JSON number as a whole integer within the JavaScript safe integer
/// range. Decimal and exponent forms whose fractional part is zero (e.g. <c>42.0</c>, <c>4.2e1</c>)
/// are accepted; non-integers and out-of-range values are rejected.
/// </summary>
private static bool TryGetCanonicalSafeInteger(JsonElement element, out long value)
{
if (element.TryGetInt64(out value))
{
return value >= -MaxSafeInteger && value <= MaxSafeInteger;
}
// Handle decimal/exponent representations of whole numbers such as "42.0" or "4.2e1".
// long.TryParse inspects the actual digits (so non-integers such as "42.5" are rejected
// without rounding) and fails fast on overflow (no large-number allocation).
const NumberStyles Styles = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent;
if (long.TryParse(element.GetRawText(), Styles, CultureInfo.InvariantCulture, out long parsed) &&
parsed >= -MaxSafeInteger && parsed <= MaxSafeInteger)
{
value = parsed;
return true;
}
value = 0;
return false;
}
/// <summary>
/// Validates a tool's <c>inputSchema</c> for valid <c>x-mcp-header</c> annotations.
/// Returns <see langword="true"/> if the tool is valid; <see langword="false"/> with a reason if it should be rejected.
/// </summary>
internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason)
{
rejectionReason = null;
if (tool.InputSchema.ValueKind != JsonValueKind.Object ||
!tool.InputSchema.TryGetProperty("properties", out var properties) ||
properties.ValueKind != JsonValueKind.Object)
{
return true;
}
var headerNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return ValidateProperties(tool, properties, headerNames, out rejectionReason);
}
/// <summary>
/// Recursively validates properties at any nesting depth for valid <c>x-mcp-header</c> annotations.
/// </summary>
private static bool ValidateProperties(Tool tool, JsonElement properties, HashSet<string> headerNames, out string? rejectionReason)
{
rejectionReason = null;
foreach (var property in properties.EnumerateObject())
{
// Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas)
if (property.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
// Recurse into nested object properties
if (property.Value.TryGetProperty("properties", out var nestedProperties) &&
nestedProperties.ValueKind == JsonValueKind.Object)
{
if (!ValidateProperties(tool, nestedProperties, headerNames, out rejectionReason))
{
return false;
}
}
if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement))
{
continue;
}
// x-mcp-header value must be a string
if (headerNameElement.ValueKind != JsonValueKind.String)
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is not a string.";
return false;
}
var headerName = headerNameElement.GetString();
// MUST NOT be empty
if (string.IsNullOrEmpty(headerName))
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is empty.";
return false;
}
// MUST match HTTP field-name token syntax (1*tchar, RFC 9110 Section 5.1)
// MUST NOT contain control characters including CR and LF
int invalidIdx = FindFirstNonTchar(headerName!);
if (invalidIdx >= 0)
{
char c = headerName![invalidIdx];
rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2}).";
return false;
}
// MUST be case-insensitively unique
if (!headerNames.Add(headerName!))
{
rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive).";
return false;
}
// MUST only be applied to parameters with primitive types (number, string, boolean) per
// SEP-2243. We also accept "integer" as a JSON Schema refinement of "number". The "type"
// keyword may be omitted (treated as unknown, not rejected, since many valid schemas
// constrain the value via enum/const/$ref instead) or expressed as a JSON Schema union
// array such as ["string", "null"]; only an explicitly disallowed or malformed type
// causes rejection.
if (property.Value.TryGetProperty("type", out var typeElement) &&
!IsAllowedHeaderType(typeElement))
{
rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', 'number', and 'boolean' are allowed.";
return false;
}
}
return true;
}
/// <summary>
/// Determines whether a JSON Schema <c>type</c> keyword is compatible with <c>x-mcp-header</c>,
/// which per SEP-2243 may only be applied to <c>number</c>, <c>string</c>, or <c>boolean</c>
/// parameters. We additionally accept <c>integer</c> as a JSON Schema refinement of <c>number</c>.
/// A union array (e.g., <c>["string", "null"]</c>) is allowed as long as it contains at least
/// one allowed primitive; <c>"null"</c> is tolerated only as an additional union member. Any
/// other shape (a disallowed type name, a non-string array element, an empty array, or a
/// non-string/non-array value) is treated as incompatible.
/// </summary>
private static bool IsAllowedHeaderType(JsonElement typeElement)
{
switch (typeElement.ValueKind)
{
case JsonValueKind.String:
return IsAllowedPrimitiveTypeName(typeElement.GetString());
case JsonValueKind.Array:
bool hasAllowedPrimitive = false;
foreach (var entry in typeElement.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.String)
{
return false;
}
var entryName = entry.GetString();
if (entryName == "null")
{
continue;
}
if (!IsAllowedPrimitiveTypeName(entryName))
{
return false;
}
hasAllowedPrimitive = true;
}
return hasAllowedPrimitive;
default:
// A "type" that is present but is neither a string nor an array of strings is malformed.
return false;
}
}
private static bool IsAllowedPrimitiveTypeName(string? typeName) =>
typeName is "string" or "integer" or "number" or "boolean";
// Valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2:
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
private const string TcharChars = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
#if NET
private static readonly SearchValues<char> s_tcharValues = SearchValues.Create(TcharChars);
internal static int FindFirstNonTchar(string value) =>
value.AsSpan().IndexOfAnyExcept(s_tcharValues);
#else
// Bitmap for O(1) tchar lookup. All valid chars are in 0x21-0x7E range,
// so two ulongs (128 bits) cover the entire ASCII range.
// _tcharBitmapLo covers chars 0-63, _tcharBitmapHi covers chars 64-127.
private static readonly ulong s_tcharBitmapLo = ComputeBitmapLo();
private static readonly ulong s_tcharBitmapHi = ComputeBitmapHi();
private static ulong ComputeBitmapLo()
{
ulong bitmap = 0;
foreach (char c in TcharChars)
{
if (c < 64)
{
bitmap |= 1UL << c;
}
}
return bitmap;
}
private static ulong ComputeBitmapHi()
{
ulong bitmap = 0;
foreach (char c in TcharChars)
{
if (c >= 64)
{
bitmap |= 1UL << (c - 64);
}
}
return bitmap;
}
private static bool IsTchar(char c)
{
if (c >= 128)
{
return false;
}
return c < 64
? (s_tcharBitmapLo & (1UL << c)) != 0
: (s_tcharBitmapHi & (1UL << (c - 64))) != 0;
}
internal static int FindFirstNonTchar(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (!IsTchar(value[i]))
{
return i;
}
}
return -1;
}
#endif
}