Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@

- Be careful with state transitions in the shell. Some states share the same `CosmosClient`, so disposing the old state during navigation can break the new state.
- Be careful with iterator lifetimes when returning `IAsyncEnumerable<T>`; do not dispose iterators before enumeration completes.
- MCP in this repo is HTTP-only. The `--mcp` option optionally accepts a port and defaults to `6128`.
- MCP supports two transports. `--mcp [port]` starts the HTTP server (default port `6128`). `--mcp-stdio` runs a headless server where stdin/stdout carry only MCP JSON-RPC and diagnostics go to stderr; it cannot be combined with `--mcp`, `--lsp`, `--stdio`, `-c`, `-k`, or `--clear-history`.
- In MCP stdio mode, nothing may write to the protocol stdout. Route user-facing output through `ShellOutput` (enforced by analyzer rule CZ0003), and never read console input.

## Preferred Change Pattern

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

## Unreleased

### Compatibility

- Machine-mode `watch` now requires a positive `--max` so changes are returned as a bounded final result instead of silently suppressed. Interactive watches can still follow until Ctrl+C.

### New features

- MCP stdio mode no longer loads or records shell history; existing history files are left untouched. Explicit diagnostic logging remains available.
- Add `--mcp-stdio` for client-owned, headless MCP processes with protocol-only stdin/stdout, stderr diagnostics, no HTTP listener, and shutdown on stdin EOF. Startup connection/navigation, MCP confirmations, and location subscriptions are supported. Unsupported commands, including `jq`, `theme`, and interactive-only commands, are omitted from tools and help; destructive commands remain available through confirmation.
- MCP stdio mode never opens a browser for sign-in. `--tenant`/`--hint` connections use device code sign-in with instructions on stderr, and endpoint-only connections use `DefaultAzureCredential` without its interactive browser step. Cancelling the tool call ends a pending sign-in.

### Improvements

- Cap MCP stdio startup input buffering at 8 MiB and report an explicit error when the limit is exceeded.
- Bound MCP stdio startup connection and navigation to 60 seconds and cancel pending startup when stdin closes. Report startup timeout errors on stderr with exit code 4. MCP request cancellation no longer logs an execution failure.
- Centralize shell presentation by message category. `--quiet` suppresses informational messages, progress, banners, and command echoes while preserving results, warnings, errors, and required authentication instructions, including previews shown before a confirmation. Machine-mode diagnostics use stderr without ANSI styling, and machine-mode stdout carries only the command result. MCP stdio continues to return results only through the protocol. Explicit diagnostic logging is unchanged.
- In machine mode, `theme`, `help`, and `edit` failures are now reported as structured `{ "status": "error", ... }` objects on stderr; previously they exited with code 1 and no message. Interactive and script errors from these commands are reported once.
- A new build analyzer rule (CZ0003) rejects direct `Console`/`AnsiConsole` output outside the shell's output policy.
### Fixes

- Parse `exec` options and shell words like direct commands. Bind built-in options normally and pass option-shaped words to functions and script files as positional text.
Expand Down
107 changes: 107 additions & 0 deletions CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

namespace CosmosShell.Analyzer
{
/// <summary>
/// Requires user-facing console output to go through ShellOutput, which applies the
/// quiet, machine-mode, and MCP stdio presentation policies. Direct writes bypass those
/// policies and can corrupt structured stdout or the MCP protocol stream.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DirectConsoleOutputAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "CZ0003";

private const string Category = "Usage";
private const string AllowedType = "Azure.Data.Cosmos.Shell.Core.ShellOutput";

private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticId,
"Direct console output",
"'{0}' bypasses the shell output policy; write through ShellOutput instead",
Category,
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "User-facing output must go through ShellOutput so --quiet, machine mode, and MCP stdio are honored.");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression);
}

private static void Analyze(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
if (!(context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is IMethodSymbol method))
{
return;
}

if (!IsDirectOutput(context.SemanticModel, invocation, method, context))
{
return;
}

var containingType = context.ContainingSymbol?.ContainingType;
while (containingType != null)
{
if (containingType.ToDisplayString() == AllowedType)
{
return;
}

containingType = containingType.ContainingType;
}

context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), method.ContainingType.Name + "." + method.Name));
}

private static bool IsDirectOutput(SemanticModel model, InvocationExpressionSyntax invocation, IMethodSymbol method, SyntaxNodeAnalysisContext context)
{
var type = method.ContainingType?.ToDisplayString();

// Console.Write / Console.WriteLine.
if (method.IsStatic && type == "System.Console" && method.Name.StartsWith("Write"))
{
return true;
}

// Every static AnsiConsole call except Create renders to the global console.
if (method.IsStatic && type == "Spectre.Console.AnsiConsole" && method.Name != "Create")
{
return true;
Comment thread
mkrueger marked this conversation as resolved.
}

// Console.Out.Write*, Console.Error.Write*, and AnsiConsole.Console.<extension>.
if (invocation.Expression is MemberAccessExpressionSyntax access
&& model.GetSymbolInfo(access.Expression, context.CancellationToken).Symbol is IPropertySymbol receiver
&& receiver.IsStatic)
{
var owner = receiver.ContainingType?.ToDisplayString();
if (owner == "System.Console" && (receiver.Name == "Out" || receiver.Name == "Error") && method.Name.StartsWith("Write"))
{
return true;
}

if (owner == "Spectre.Console.AnsiConsole" && receiver.Name == "Console")
{
return true;
}
}

return false;
}
}
}
1 change: 1 addition & 0 deletions CosmosDBShell.Analyzer/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.EmptyCatchClauseAnalyzer.Rule")]
[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.WriteLineAnalyzer.Rule")]
[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "<Pending>", Scope = "member", Target = "~F:CosmosShell.Analyzer.DirectConsoleOutputAnalyzer.Rule")]
6 changes: 3 additions & 3 deletions CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent()
}

[Fact]
public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState()
public async Task ExecuteAsync_InDatabase_InvalidValue_ThrowsCommandException()
{
var client = CreateTestClient();
using var shell = ShellInterpreter.CreateInstance();
shell.State = new DatabaseState("TestDatabase", client);
var command = new BucketCommand { Action = "99" };

var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None);
await Assert.ThrowsAsync<CommandException>(
() => command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None));

Assert.False(state.IsError);
Assert.Null(client.ClientOptions.ThroughputBucket);
}

Expand Down
71 changes: 71 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,77 @@ public void CreateClientOptions_Emulator_UsesShortRequestTimeout()
Assert.Equal(TimeSpan.FromSeconds(5), options.RequestTimeout);
}

[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public void AllowsInteractiveBrowser_IsDisabledOnlyForMcpStdio(bool mcpStdio, bool expected)
{
Assert.Equal(expected, ShellInterpreter.AllowsInteractiveBrowser(mcpStdio));
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void CreateDefaultCredentialOptions_ExcludesBrowserWhenNotAllowed(bool allowInteractiveBrowser)
{
var authority = new Uri("https://login.example.test/");

var options = ShellInterpreter.CreateDefaultCredentialOptions(authority, allowInteractiveBrowser);

Assert.Equal(!allowInteractiveBrowser, options.ExcludeInteractiveBrowserCredential);
Comment thread
mkrueger marked this conversation as resolved.
Assert.Equal(!allowInteractiveBrowser, options.ExcludeBrokerCredential);
Assert.Equal(authority, options.AuthorityHost);
Assert.False(options.ExcludeAzureCliCredential);
Assert.False(options.ExcludeManagedIdentityCredential);
}

[Fact]
public void IsMcpStdio_FollowsOptions()
{
using var shell = ShellInterpreter.CreateInstance();
Assert.False(shell.IsMcpStdio);

shell.Options = new Program.CosmosShellOptions { McpStdio = true };
Assert.True(shell.IsMcpStdio);
}

[Fact]
public async Task ConnectAsync_McpStdioWithTenant_UsesDeviceCodeWithoutBrowser()
{
var shell = ShellInterpreter.Instance;
var originalOptions = shell.Options;
var originalError = Console.Error;
var originalOut = Console.Out;
using var error = new StringWriter();
using var output = new StringWriter();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
try
{
shell.Options = new Program.CosmosShellOptions { McpStdio = true };
Console.SetError(error);
Console.SetOut(output);
timeout.CancelAfter(TimeSpan.FromSeconds(2));

// Device code polling cannot complete here: the attempt ends by cancellation or,
// without network access, by an authentication failure. Neither may open a browser.
await Assert.ThrowsAnyAsync<Exception>(() => shell.ConnectAsync(
"https://stdio-auth-test.documents.azure.com:443/",
tenantId: "00000000-0000-0000-0000-000000000000",
token: timeout.Token));
}
finally
{
Console.SetError(originalError);
Console.SetOut(originalOut);
shell.Options = originalOptions;
}

var stderr = error.ToString();
Assert.Contains(MessageService.GetString("shell-connect-devicecode-stdio"), stderr, StringComparison.Ordinal);
Assert.DoesNotContain(MessageService.GetString("shell-connect-browser-auth"), stderr + output, StringComparison.Ordinal);
Assert.IsNotType<ConnectedState>(shell.State);
}

[Fact]
public void ConnectivityFailure_LocalEmulator_DisconnectsShell()
{
Expand Down
40 changes: 10 additions & 30 deletions CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ public async Task Show_KnownTheme_PreviewsWithoutChangingActive(string name)
[Fact]
public async Task Show_UnknownTheme_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" }));
}

[Fact]
Expand Down Expand Up @@ -167,25 +165,19 @@ public async Task Set_AliasOfUse_AppliesTheme()
[Fact]
public async Task Use_MissingName_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "use" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "use" }));
}

[Fact]
public async Task Use_UnknownName_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" }));
}

[Fact]
public async Task Load_MissingPath_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "load" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "load" }));
}

[Fact]
Expand Down Expand Up @@ -214,9 +206,7 @@ public async Task Load_NonExistentFile_ReturnsError()
{
var path = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.toml");

var state = await RunAsync(new ThemeCommand { Action = "load", Name = path });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "load", Name = path }));
}

[Fact]
Expand All @@ -225,9 +215,7 @@ public async Task Load_InvalidToml_ReturnsError()
var path = WriteTempToml($"bad-{Guid.NewGuid():N}", "this is = = not valid toml [[[");
try
{
var state = await RunAsync(new ThemeCommand { Action = "load", Name = path });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "load", Name = path }));
}
finally
{
Expand All @@ -238,17 +226,13 @@ public async Task Load_InvalidToml_ReturnsError()
[Fact]
public async Task Save_MissingName_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "save" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "save" }));
}

[Fact]
public async Task Save_InvalidName_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "save", Name = "../escape" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "save", Name = "../escape" }));
}

[Fact]
Expand Down Expand Up @@ -280,9 +264,7 @@ public async Task Save_ExistingFileWithoutForce_ReturnsError()
File.WriteAllText(path, "name = \"existing\"");
try
{
var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path }));
}
finally
{
Expand Down Expand Up @@ -321,9 +303,7 @@ public async Task Reload_RescansUserThemesDirectory()
[Fact]
public async Task UnknownAction_ReturnsError()
{
var state = await RunAsync(new ThemeCommand { Action = "frobnicate" });

Assert.IsType<ErrorCommandState>(state);
await Assert.ThrowsAsync<CommandException>(() => RunAsync(new ThemeCommand { Action = "frobnicate" }));
}

private static Task<CommandState> RunAsync(ThemeCommand command) =>
Expand Down
10 changes: 4 additions & 6 deletions CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,9 @@ public async Task Validate_ScansDirectoryAndReportsCounts()
Name = dir.Path,
};

var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None);
var error = Assert.IsType<ErrorCommandState>(state);
var error = await Assert.ThrowsAsync<CommandException>(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None));

Assert.Contains("1 of 2", error.Exception.Message);
Assert.Contains("1 of 2", error.Message);
}

[Fact]
Expand All @@ -109,9 +108,8 @@ public async Task Validate_StrictTreatsWarningsAsErrors()
Strict = true,
};

var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None);
var error = Assert.IsType<ErrorCommandState>(state);
Assert.Contains("strict mode", error.Exception.Message);
var error = await Assert.ThrowsAsync<CommandException>(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None));
Assert.Contains("strict mode", error.Message);
}
finally
{
Expand Down
Loading
Loading