Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions CosmosDBShell.Tests/ToolOperationsCallToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace CosmosShell.Tests;

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
Expand Down Expand Up @@ -233,6 +234,35 @@ public async Task CallTool_RmWithJsonNullSafetyOption_ConfirmedCommandFailsWitho
Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
}

[Theory]
[InlineData("etag", "command-rm-error-etag_empty")]
[InlineData("partition-key", "command-rm-error-partition_key_missing_value")]
[InlineData("pk", "command-rm-error-partition_key_missing_value")]
public async Task CallTool_NullRmSafetyOption_ReturnsErrorBeforeConfirmation(string argumentName, string errorKey)
{
var history = ShellInterpreter.Instance.History.ToArray();
var arguments = new Dictionary<string, JsonElement>
{
["pattern"] = Json("\"order-123\""),
["key"] = Json("\"id\""),
[argumentName] = Json("null"),
};

var result = await CreateToolOperations().CallToolHandler(
CallContext("rm", arguments), TestContext.Current.CancellationToken);

var (isError, root, document) = ReadResult(result);
using (document)
{
Assert.True(isError);
Assert.Equal(
Azure.Data.Cosmos.Shell.Util.MessageService.GetString(errorKey),
root.GetProperty("error").GetString());
}

Assert.Equal(history, ShellInterpreter.Instance.History);
}

[Fact]
public async Task ConfirmDestructive_UserAccepts_ReturnsNull()
{
Expand Down Expand Up @@ -445,6 +475,93 @@ public void FormatPositionalsForHistory_RendersContiguousValuesAndExpandsArrays(
Assert.Equal(" \"hello\" \"world\"", ToolOperations.FormatPositionalsForHistory(echoParameters, echoValues));
}

[Fact]
public void HistoryFormatting_UsesInvariantCultureForOptionsAndPositionals()
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");

var intervalOption = ShellInterpreter.Instance.App.Commands["watch"].Options.Single(o => o.Name[0] == "interval");
Assert.Equal(" --interval \"1.5\"", ToolOperations.FormatOptionForHistory(intervalOption, 1.5d));

var echoParameters = ShellInterpreter.Instance.App.Commands["echo"].Parameters;
var echoValues = new Dictionary<Parameter, object?> { [echoParameters[0]] = new object?[] { 1.5d, 2.5d } };
Assert.Equal(" \"1.5\" \"2.5\"", ToolOperations.FormatPositionalsForHistory(echoParameters, echoValues));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}

[Fact]
public async Task CallTool_NullNullableValueOption_IsOmitted()
{
var tool = CreateToolOperations();
var shell = ShellInterpreter.Instance;
var originalState = shell.State;
var arguments = new Dictionary<string, JsonElement>
{
["query"] = Json("\"SELECT * FROM c\""),
["max"] = Json("null"),
};

try
{
shell.State = new DisconnectedState();

var result = await tool.CallToolHandler(CallContext("query", arguments), CancellationToken.None);

var (isError, root, document) = ReadResult(result);
using (document)
{
Assert.True(isError);
Assert.DoesNotContain("Invalid value", root.GetProperty("error").GetString(), StringComparison.Ordinal);
}

Assert.Equal("query \"SELECT * FROM c\"", shell.History.ToArray()[^1]);
}
finally
{
shell.State = originalState;
}
}

[Fact]
public async Task CallTool_NullStringOption_IsOmitted()
{
var tool = CreateToolOperations();
var shell = ShellInterpreter.Instance;
var originalState = shell.State;
var arguments = new Dictionary<string, JsonElement>
{
["query"] = Json("\"SELECT * FROM c\""),
["database"] = Json("null"),
};

try
{
shell.State = new DisconnectedState();

var result = await tool.CallToolHandler(CallContext("query", arguments), CancellationToken.None);

var (isError, root, document) = ReadResult(result);
using (document)
{
Assert.True(isError);
Assert.DoesNotContain("Invalid value", root.GetProperty("error").GetString(), StringComparison.Ordinal);
}

Assert.Equal("query \"SELECT * FROM c\"", shell.History.ToArray()[^1]);
}
finally
{
shell.State = originalState;
}
}

[Fact]
public async Task CallTool_UnknownArgument_ReturnsErrorListingKnownArguments()
{
Expand Down
4 changes: 4 additions & 0 deletions CosmosDBShell.Tests/ToolOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ public void GetTool_ExposesContinuationWithoutMakingItAShellOption(string comman
var continuation = schema.GetProperty("properties").GetProperty("continuation");

Assert.Equal("string", continuation.GetProperty("type").GetString());
var description = continuation.GetProperty("description").GetString();
Assert.Contains("unless resultIncomplete is true", description);
Assert.Contains("truncated and cannot be resumed", description);
Assert.Contains("Never pass back a null token", description);
}

[Fact]
Expand Down
46 changes: 41 additions & 5 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Azure.Data.Cosmos.Shell.Mcp;

using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -31,7 +32,7 @@ internal class ToolOperations
"The shell context changed while awaiting confirmation. Nothing was executed. Retry the command and confirm its current target.";

private const string ContinuationDescription =
"Non-null continuation token returned by a previous call to this tool. Pass it back to fetch the next page, or omit this argument to start from the beginning. A null output token means the result is exhausted and no further call should be made. The value is opaque; do not modify it.";
"Non-null continuation token returned by a previous call to this tool. Pass it back to fetch the next page, or omit this argument to start from the beginning. A null output token means the result is exhausted unless resultIncomplete is true, in which case results were truncated and cannot be resumed. Never pass back a null token. For incomplete results, retry with a larger max or a narrower query. The value is opaque; do not modify it.";

private readonly ILogger<ToolOperations> logger;
private readonly LocationResourceSubscriptions locationSubscriptions;
Expand Down Expand Up @@ -234,7 +235,7 @@ internal static bool MatchesArgumentName(string[] names, string? argumentName)

internal static string FormatOptionForHistory(Option option, object? value)
{
return $" --{option.Name[0]} {ShellLiteral.Quote(value?.ToString())}";
return $" --{option.Name[0]} {ShellLiteral.Quote(FormatValueForHistory(value))}";
}

// Shell syntax cannot skip a positional, so a later value would bind to the omitted slot on replay.
Expand Down Expand Up @@ -271,18 +272,23 @@ internal static string FormatPositionalsForHistory(IReadOnlyList<Parameter> para
{
foreach (var element in array)
{
sb.Append(' ').Append(ShellLiteral.Quote(element?.ToString()));
sb.Append(' ').Append(ShellLiteral.Quote(FormatValueForHistory(element)));
}
}
else
{
sb.Append(' ').Append(ShellLiteral.Quote(value?.ToString()));
sb.Append(' ').Append(ShellLiteral.Quote(FormatValueForHistory(value)));
}
}

return sb.ToString();
}

private static string? FormatValueForHistory(object? value)
{
return Convert.ToString(value, CultureInfo.InvariantCulture);
}

private static bool IsPositionalSupplied(IReadOnlyDictionary<Parameter, object?> values, Parameter parameter)
{
return values.TryGetValue(parameter, out var value)
Expand Down Expand Up @@ -434,6 +440,11 @@ private static bool RequiresConfirmation(CommandFactory command)
&& annotation.Destructive;
}

private static bool IsExplicitJsonNull(JsonElement value)
{
return value.ValueKind is JsonValueKind.Null;
}

private CallToolResult? BindMember(
object cmd,
PropertyInfo property,
Expand All @@ -454,7 +465,7 @@ private static bool RequiresConfirmation(CommandFactory command)
{
convertedValue = rawValue is JsonElement jsonElement
? ConvertJsonElement(jsonElement, targetType)
: Convert.ChangeType(rawValue, targetType);
: Convert.ChangeType(rawValue, targetType, CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -554,6 +565,26 @@ private async ValueTask<CallToolResult> OnCallToolsAsync(
var option = command.Options.FirstOrDefault(a => MatchesArgumentName(a.Name, par.Key));
if (option != null)
{
if (IsExplicitJsonNull(par.Value))
{
var safetyErrorKey = cmd is RmCommand
? option.PropertyInfo.Name switch
{
nameof(RmCommand.ETag) => "command-rm-error-etag_empty",
nameof(RmCommand.PartitionKeyArgument) => "command-rm-error-partition_key_missing_value",
_ => null,
}
: null;
if (safetyErrorKey != null)
{
var errorMessage = MessageService.GetString(safetyErrorKey);
this.logger?.LogWarning("{Message}", errorMessage);
return McpResponseFactory.CreateError(errorMessage, ShellInterpreter.Instance.State);
}

continue;
}

var bindError = this.BindMember(
cmd,
option.PropertyInfo,
Expand All @@ -573,6 +604,11 @@ private async ValueTask<CallToolResult> OnCallToolsAsync(
var parameter = command.Parameters.FirstOrDefault(a => MatchesArgumentName(a.Name, par.Key));
if (parameter != null)
{
if (IsExplicitJsonNull(par.Value))
{
continue;
}

var bindError = this.BindMember(
cmd,
parameter.PropertyInfo,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ A terminal-native shell for Azure Cosmos DB — navigate databases like a filesy

Exports replace their destination only after successful completion, preserving an existing file on failure or cancellation. Imports stream records; CSV exports use temporary disk storage to discover columns without retaining all documents in memory. Scalar CSV results use an empty column header by default; set `COSMOSDB_SHELL_CSV_SCALAR_COLUMN` to name it. An import rejects populated columns with empty headers rather than discarding their values. See [import/export](docs/commands.md#export).

MCP command execution is serialized with the shell, and destructive confirmations are invalidated by connection or navigation changes. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. Concurrent shells merge history under a shared lock and publish complete replacements instead of truncating the saved file. History remains fully replayable, including connection strings; treat its file as sensitive. See [MCP security](docs/mcp.md#security) and [history](docs/navigation.md#history).
MCP command execution is serialized with the shell, and destructive confirmations are invalidated by connection or navigation changes. Ordinary explicit null MCP arguments are omitted; null continuation tokens and null `rm` partition-key/ETag safety options are rejected. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. Concurrent shells merge history under a shared lock and publish complete replacements instead of truncating the saved file. History remains fully replayable, including connection strings; treat its file as sensitive. See [MCP security](docs/mcp.md#security) and [history](docs/navigation.md#history).

MCP clients supporting resource subscriptions can watch `cosmos://shell/current-location` for interactive navigation and connection changes; the resource includes the current account endpoint separately from the location. See [MCP location updates](docs/mcp.md#shell-location-updates).

Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ MCP tool invocations are echoed as command lines in the shell window, so anyone

Positional arguments must be supplied without gaps: a call that provides a positional parameter while omitting an earlier one is rejected, because the equivalent shell command line would bind the value to the omitted slot.

Explicit `null` argument values are treated as omitted and are not echoed into shell history. The paging `continuation` argument is an exception: a `null` output token means the result set is exhausted unless `resultIncomplete` is `true`, in which case results were truncated and cannot be resumed. A `null` token must never be passed back as `continuation`; doing so is rejected in either case. For incomplete results, retry with a larger `max` or a narrower query. The `rm` safety options `partition-key` (including alias `pk`) and `etag` also reject explicit `null` values rather than silently dropping the partition scope or concurrency check.

Your MCP client may use a remote LLM. Command outputs, query results, and file contents could be transmitted to external services. **Treat all shell output as potentially shared.**

### Best Practices
Expand Down
Loading