Skip to content

Commit a78fe26

Browse files
Fix single-quoted timezone examples in schemas and add lenient JSON argument parser
- Replace single-quoted IANA examples ('America/Chicago') with backtick notation in all tool Description attributes so LLMs don't echo back single quotes - TryGetTimeZone now strips surrounding single quotes or backticks before lookup as a defensive fallback for LLMs that still echo the old style - Add ToolArgumentParser utility with NormalizeSingleQuotedJson and ParseArguments<T> for lenient parsing of JS-style single-quoted JSON objects Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 7156636 commit a78fe26

7 files changed

Lines changed: 281 additions & 7 deletions

File tree

src/CalendarMcp.Core/Tools/CreateEventTool.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public sealed class CreateEventTool(
1515
IProviderServiceFactory providerFactory,
1616
ILogger<CreateEventTool> logger)
1717
{
18-
[McpServerTool, Description("Create a calendar event. Always pass the timeZone parameter using the user's local IANA timezone (e.g. 'America/Chicago', 'America/New_York', 'Europe/London') so events are created at the correct local time. Requires explicit account selection or smart routing.")]
18+
[McpServerTool, Description("Create a calendar event. Always pass the timeZone parameter using the user's local IANA timezone (e.g. `America/Chicago`, `America/New_York`, `Europe/London`) so events are created at the correct local time. Requires explicit account selection or smart routing.")]
1919
public async Task<string> CreateEvent(
2020
[Description("Event subject/title")] string subject,
2121
[Description("Event start date and time (ISO 8601 format)")] DateTime start,
@@ -25,7 +25,7 @@ public async Task<string> CreateEvent(
2525
[Description("Event location")] string? location = null,
2626
[Description("List of attendee email addresses")] List<string>? attendees = null,
2727
[Description("Event description/body")] string? body = null,
28-
[Description("IANA timezone name for the event (e.g. 'America/Chicago', 'America/New_York', 'Europe/London'). Required to create events at the correct local time.")] string? timeZone = null)
28+
[Description("IANA timezone name for the event (e.g. `America/Chicago`, `America/New_York`, `Europe/London`). Required to create events at the correct local time.")] string? timeZone = null)
2929
{
3030
// Strip CDATA wrappers if present (LLMs sometimes wrap content in XML CDATA)
3131
body = StripCdataWrapper(body);

src/CalendarMcp.Core/Tools/GetCalendarEventDetailsTool.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public sealed class GetCalendarEventDetailsTool(
1919
{
2020
[McpServerTool, Description("Get full details for a single calendar event including attendee responses, free/busy status, recurrence pattern, and online meeting link. Use this after get_calendar_events to fetch richer data for a specific event.")]
2121
public async Task<string> GetCalendarEventDetails(
22-
[Description("IANA timezone name for displaying event times (e.g. 'America/Chicago', 'America/New_York', 'Europe/London', 'Asia/Tokyo'). All event times are returned in both UTC and this local timezone.")] string timeZone,
22+
[Description("IANA timezone name for displaying event times (e.g. `America/Chicago`, `America/New_York`, `Europe/London`, `Asia/Tokyo`). All event times are returned in both UTC and this local timezone.")] string timeZone,
2323
[Description("Account ID from get_calendar_events")] string accountId,
2424
[Description("Calendar ID from get_calendar_events, or 'primary' for the default calendar")] string calendarId,
2525
[Description("Event ID from get_calendar_events")] string eventId)

src/CalendarMcp.Core/Tools/GetCalendarEventsTool.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ public sealed class GetCalendarEventsTool(
1919
{
2020
[McpServerTool, Description("Get calendar events for a date range from one or all accounts. The timeZone parameter is required. Returns events sorted by start time, each with: id, accountId, calendarId, subject, start/end in both UTC and local time, timezone, location, attendees, isAllDay, organizer. Use the returned accountId and id when calling delete_event, respond_to_event, or get_calendar_event_details.")]
2121
public async Task<string> GetCalendarEvents(
22-
[Description("IANA timezone name for displaying event times (e.g. 'America/Chicago', 'America/New_York', 'Europe/London', 'Asia/Tokyo'). All event times are returned in both UTC and this local timezone. Required.")] string timeZone,
23-
[Description("Start of the date range (ISO 8601 format, e.g. '2026-02-20'). Defaults to today.")] DateTime? startDate = null,
24-
[Description("End of the date range (ISO 8601 format, e.g. '2026-02-27'). Defaults to 7 days after startDate.")] DateTime? endDate = null,
22+
[Description("IANA timezone name for displaying event times (e.g. `America/Chicago`, `America/New_York`, `Europe/London`, `Asia/Tokyo`). All event times are returned in both UTC and this local timezone. Required.")] string timeZone,
23+
[Description("Start of the date range (ISO 8601 format, e.g. `2026-02-20`). Defaults to today.")] DateTime? startDate = null,
24+
[Description("End of the date range (ISO 8601 format, e.g. `2026-02-27`). Defaults to 7 days after startDate.")] DateTime? endDate = null,
2525
[Description("Account ID to query, or omit to query all accounts. Obtain from list_accounts.")] string? accountId = null,
2626
[Description("Calendar ID to query, or omit for all calendars. Obtain from list_calendars.")] string? calendarId = null,
2727
[Description("Maximum number of events to return per account (default 50)")] int count = 50)

src/CalendarMcp.Core/Utilities/TimeZoneHelper.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,23 @@ public static string ToLocalString(DateTimeOffset dto, TimeZoneInfo timeZone)
2626

2727
/// <summary>
2828
/// Tries to find a TimeZoneInfo by IANA timezone ID. Returns null if invalid.
29+
/// Strips surrounding single quotes or backticks that LLMs sometimes echo from schema examples.
2930
/// </summary>
3031
public static TimeZoneInfo? TryGetTimeZone(string? timeZoneId)
3132
{
3233
if (string.IsNullOrWhiteSpace(timeZoneId))
3334
return null;
3435

36+
var id = timeZoneId.Trim();
37+
38+
// Strip surrounding single quotes ('America/Chicago') or backticks (`America/Chicago`)
39+
// that LLMs may echo verbatim from schema description examples.
40+
if (id.Length >= 2 && id[0] == id[^1] && (id[0] == '\'' || id[0] == '`'))
41+
id = id[1..^1].Trim();
42+
3543
try
3644
{
37-
return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
45+
return TimeZoneInfo.FindSystemTimeZoneById(id);
3846
}
3947
catch (TimeZoneNotFoundException)
4048
{
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
4+
namespace CalendarMcp.Core.Utilities;
5+
6+
/// <summary>
7+
/// Helpers for parsing tool arguments that may arrive as JS-style (single-quoted) JSON
8+
/// rather than strict JSON, which some LLMs produce when they echo schema examples.
9+
/// </summary>
10+
public static class ToolArgumentParser
11+
{
12+
/// <summary>
13+
/// Deserializes a JSON string, falling back to lenient single-quoted JS normalization
14+
/// if strict parsing fails.
15+
/// </summary>
16+
public static T? ParseArguments<T>(string json, JsonSerializerOptions? options = null)
17+
{
18+
if (string.IsNullOrWhiteSpace(json))
19+
return default;
20+
21+
// Strict parse first
22+
try
23+
{
24+
return JsonSerializer.Deserialize<T>(json, options);
25+
}
26+
catch (JsonException) { }
27+
28+
// Lenient fallback: normalize single-quoted / unquoted-key JS syntax
29+
var normalized = NormalizeSingleQuotedJson(json);
30+
return JsonSerializer.Deserialize<T>(normalized, options);
31+
}
32+
33+
/// <summary>
34+
/// Converts a JS-style single-quoted or bare-key JSON string to strict JSON.
35+
/// <list type="bullet">
36+
/// <item>Single-quoted strings: <c>'value'</c> → <c>"value"</c></item>
37+
/// <item>Unquoted object keys: <c>{key: 'v'}</c> → <c>{"key": "v"}</c></item>
38+
/// <item>Double-quotes inside single-quoted strings are escaped.</item>
39+
/// </list>
40+
/// Returns the input unchanged if it is already valid JSON.
41+
/// </summary>
42+
public static string NormalizeSingleQuotedJson(string input)
43+
{
44+
if (string.IsNullOrWhiteSpace(input))
45+
return input;
46+
47+
// Fast path: already valid
48+
try
49+
{
50+
JsonDocument.Parse(input);
51+
return input;
52+
}
53+
catch (JsonException) { }
54+
55+
var sb = new StringBuilder(input.Length + 16);
56+
int i = 0;
57+
bool inDouble = false;
58+
bool inSingle = false;
59+
bool escaped = false;
60+
61+
while (i < input.Length)
62+
{
63+
char c = input[i];
64+
65+
// Handle escape sequences inside strings
66+
if (escaped)
67+
{
68+
sb.Append(c);
69+
escaped = false;
70+
i++;
71+
continue;
72+
}
73+
74+
if (c == '\\' && (inDouble || inSingle))
75+
{
76+
sb.Append(c);
77+
escaped = true;
78+
i++;
79+
continue;
80+
}
81+
82+
// Track double-quoted string context
83+
if (c == '"' && !inSingle)
84+
{
85+
inDouble = !inDouble;
86+
sb.Append(c);
87+
i++;
88+
continue;
89+
}
90+
91+
// Single-quote transitions: open → emit ", close → emit "
92+
if (c == '\'' && !inDouble)
93+
{
94+
inSingle = !inSingle;
95+
sb.Append('"');
96+
i++;
97+
continue;
98+
}
99+
100+
// Inside a single-quoted string: escape any bare double-quotes
101+
if (inSingle && c == '"')
102+
{
103+
sb.Append('\\');
104+
sb.Append('"');
105+
i++;
106+
continue;
107+
}
108+
109+
// Outside strings: quote bare object/array keys after { or ,
110+
if (!inDouble && !inSingle && (c == '{' || c == ','))
111+
{
112+
sb.Append(c);
113+
i++;
114+
115+
// Consume and emit whitespace
116+
while (i < input.Length && char.IsWhiteSpace(input[i]))
117+
{
118+
sb.Append(input[i]);
119+
i++;
120+
}
121+
122+
// If the next char starts a bare identifier (not a quote or structural char),
123+
// wrap it in double quotes.
124+
if (i < input.Length)
125+
{
126+
char next = input[i];
127+
if (next != '"' && next != '\'' && next != '}' && next != ']'
128+
&& (char.IsLetter(next) || next == '_'))
129+
{
130+
sb.Append('"');
131+
while (i < input.Length && (char.IsLetterOrDigit(input[i]) || input[i] == '_'))
132+
{
133+
sb.Append(input[i]);
134+
i++;
135+
}
136+
sb.Append('"');
137+
}
138+
}
139+
140+
continue;
141+
}
142+
143+
sb.Append(c);
144+
i++;
145+
}
146+
147+
return sb.ToString();
148+
}
149+
}

src/CalendarMcp.Tests/Utilities/TimeZoneHelperTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,28 @@ public void TryGetTimeZone_EmptyInput_ReturnsNull()
6767
var tz = TimeZoneHelper.TryGetTimeZone("");
6868
Assert.IsNull(tz);
6969
}
70+
71+
[TestMethod]
72+
public void TryGetTimeZone_SingleQuotedId_ReturnsTimeZoneInfo()
73+
{
74+
// LLMs sometimes echo schema examples verbatim, producing 'America/Chicago'
75+
var tz = TimeZoneHelper.TryGetTimeZone("'America/Chicago'");
76+
Assert.IsNotNull(tz);
77+
Assert.AreEqual("America/Chicago", tz.Id);
78+
}
79+
80+
[TestMethod]
81+
public void TryGetTimeZone_BacktickQuotedId_ReturnsTimeZoneInfo()
82+
{
83+
var tz = TimeZoneHelper.TryGetTimeZone("`America/Chicago`");
84+
Assert.IsNotNull(tz);
85+
Assert.AreEqual("America/Chicago", tz.Id);
86+
}
87+
88+
[TestMethod]
89+
public void TryGetTimeZone_MismatchedQuotes_ReturnsNull()
90+
{
91+
var tz = TimeZoneHelper.TryGetTimeZone("'America/Chicago`");
92+
Assert.IsNull(tz);
93+
}
7094
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using CalendarMcp.Core.Utilities;
2+
3+
namespace CalendarMcp.Tests.Utilities;
4+
5+
[TestClass]
6+
public class ToolArgumentParserTests
7+
{
8+
// ── NormalizeSingleQuotedJson ────────────────────────────────────────────
9+
10+
[TestMethod]
11+
public void Normalize_AlreadyValidJson_ReturnsUnchanged()
12+
{
13+
const string input = """{"timeZone":"America/Chicago"}""";
14+
Assert.AreEqual(input, ToolArgumentParser.NormalizeSingleQuotedJson(input));
15+
}
16+
17+
[TestMethod]
18+
public void Normalize_SingleQuotedValues_ConvertsToDoubleQuotes()
19+
{
20+
var result = ToolArgumentParser.NormalizeSingleQuotedJson("{'timeZone':'America/Chicago'}");
21+
Assert.AreEqual("""{"timeZone":"America/Chicago"}""", result);
22+
}
23+
24+
[TestMethod]
25+
public void Normalize_BareKeys_QuotesKeys()
26+
{
27+
var result = ToolArgumentParser.NormalizeSingleQuotedJson("{timeZone:'America/Chicago'}");
28+
Assert.AreEqual("""{"timeZone":"America/Chicago"}""", result);
29+
}
30+
31+
[TestMethod]
32+
public void Normalize_MultipleFields_HandlesAll()
33+
{
34+
var result = ToolArgumentParser.NormalizeSingleQuotedJson(
35+
"{timeZone:'America/Chicago',startDate:'2026-01-01'}");
36+
Assert.AreEqual("""{"timeZone":"America/Chicago","startDate":"2026-01-01"}""", result);
37+
}
38+
39+
[TestMethod]
40+
public void Normalize_DoubleQuoteInsideSingleQuotedString_EscapesIt()
41+
{
42+
// Single-quoted string containing a double-quote character
43+
var result = ToolArgumentParser.NormalizeSingleQuotedJson("{msg:'say \"hi\"'}");
44+
Assert.AreEqual("""{"msg":"say \"hi\""}""", result);
45+
}
46+
47+
[TestMethod]
48+
public void Normalize_MixedQuotedKeys_HandlesDoubleQuotedKeys()
49+
{
50+
// Keys already in double quotes should not be double-wrapped
51+
var result = ToolArgumentParser.NormalizeSingleQuotedJson("""{"timeZone":'America/Chicago'}""");
52+
Assert.AreEqual("""{"timeZone":"America/Chicago"}""", result);
53+
}
54+
55+
[TestMethod]
56+
public void Normalize_NullOrWhitespace_ReturnsUnchanged()
57+
{
58+
Assert.AreEqual("", ToolArgumentParser.NormalizeSingleQuotedJson(""));
59+
Assert.AreEqual(" ", ToolArgumentParser.NormalizeSingleQuotedJson(" "));
60+
}
61+
62+
// ── ParseArguments ───────────────────────────────────────────────────────
63+
64+
private record TzArgs(string TimeZone, string? StartDate = null);
65+
66+
private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitive =
67+
new() { PropertyNameCaseInsensitive = true };
68+
69+
[TestMethod]
70+
public void ParseArguments_StrictJson_Deserializes()
71+
{
72+
var result = ToolArgumentParser.ParseArguments<TzArgs>(
73+
"""{"timeZone":"America/Chicago"}""", CaseInsensitive);
74+
Assert.IsNotNull(result);
75+
Assert.AreEqual("America/Chicago", result.TimeZone);
76+
}
77+
78+
[TestMethod]
79+
public void ParseArguments_SingleQuotedJson_DeserializesViaFallback()
80+
{
81+
var result = ToolArgumentParser.ParseArguments<TzArgs>(
82+
"{timeZone:'America/Chicago'}", CaseInsensitive);
83+
Assert.IsNotNull(result);
84+
Assert.AreEqual("America/Chicago", result.TimeZone);
85+
}
86+
87+
[TestMethod]
88+
public void ParseArguments_NullOrWhitespace_ReturnsDefault()
89+
{
90+
Assert.IsNull(ToolArgumentParser.ParseArguments<TzArgs>(null!));
91+
Assert.IsNull(ToolArgumentParser.ParseArguments<TzArgs>(""));
92+
}
93+
}

0 commit comments

Comments
 (0)