diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c2e9bb..35d1a0d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+### New features
+
+- `sproc` command to manage Cosmos DB for NoSQL stored procedures on the current container: `list`, `show`, `exists` (returns a boolean usable in `if`/`while` conditions), `create` (from a JavaScript file or piped body, with `--force` to replace), `exec` (with a JSON argument array and `--partition-key`), `edit` (interactive external editor), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103))
+
## 1.1.4-preview — 2026-05-21
First release on the 1.1 line. A pretty packed cycle. The headline change is **ARM-based control plane for database and container management**, but there’s also a fully reworked CLI, two new item commands, a much friendlier shell experience for newcomers, and a long list of paper-cut fixes.
diff --git a/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs
new file mode 100644
index 0000000..1440f34
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/SprocCommandTests.cs
@@ -0,0 +1,116 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using System.Text.Json;
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Unit tests for the pure helpers on : subcommand
+/// normalization, execution-parameter parsing, and partition-key parsing.
+///
+public class SprocCommandTests
+{
+ [Theory]
+ [InlineData("LIST", "list")]
+ [InlineData(" Show ", "show")]
+ [InlineData("Create", "create")]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ public void NormalizeSubcommand_TrimsAndLowercases(string? input, string expected)
+ {
+ Assert.Equal(expected, SprocCommand.NormalizeSubcommand(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ParseExecParams_NullOrWhitespace_ReturnsEmpty(string? input)
+ {
+ Assert.Empty(SprocCommand.ParseExecParams(input));
+ }
+
+ [Fact]
+ public void ParseExecParams_JsonArray_ReturnsElements()
+ {
+ var parameters = SprocCommand.ParseExecParams("[\"a\", 1, true]");
+
+ Assert.Equal(3, parameters.Length);
+ Assert.Equal("a", ((JsonElement)parameters[0]).GetString());
+ Assert.Equal(1, ((JsonElement)parameters[1]).GetInt32());
+ Assert.True(((JsonElement)parameters[2]).GetBoolean());
+ }
+
+ [Fact]
+ public void ParseExecParams_EmptyArray_ReturnsEmpty()
+ {
+ Assert.Empty(SprocCommand.ParseExecParams("[]"));
+ }
+
+ [Theory]
+ [InlineData("not json")]
+ [InlineData("{\"a\":1}")]
+ [InlineData("\"justAString\"")]
+ [InlineData("42")]
+ public void ParseExecParams_NonArray_Throws(string input)
+ {
+ Assert.Throws(() => SprocCommand.ParseExecParams(input));
+ }
+
+ [Fact]
+ public void ParsePartitionKey_String_MatchesStringPartitionKey()
+ {
+ Assert.Equal(new PartitionKey("pk1").ToString(), SprocCommand.ParsePartitionKey("pk1").ToString());
+ }
+
+ [Fact]
+ public void ParsePartitionKey_QuotedString_PreservesStringType()
+ {
+ Assert.Equal(new PartitionKey("pk1").ToString(), SprocCommand.ParsePartitionKey("\"pk1\"").ToString());
+ }
+
+ [Fact]
+ public void ParsePartitionKey_Number_PreservesNumericType()
+ {
+ Assert.Equal(new PartitionKey(42).ToString(), SprocCommand.ParsePartitionKey("42").ToString());
+ }
+
+ [Fact]
+ public void ParsePartitionKey_Boolean_PreservesBooleanType()
+ {
+ Assert.Equal(new PartitionKey(true).ToString(), SprocCommand.ParsePartitionKey("true").ToString());
+ }
+
+ [Fact]
+ public void ParsePartitionKey_JsonArray_BuildsHierarchicalPartitionKey()
+ {
+ var expected = new PartitionKeyBuilder()
+ .Add("tenant")
+ .Add("user")
+ .Build();
+
+ Assert.Equal(expected.ToString(), SprocCommand.ParsePartitionKey("[\"tenant\",\"user\"]").ToString());
+ }
+
+ [Fact]
+ public void ParsePartitionKey_JsonObject_Throws()
+ {
+ Assert.Throws(() => SprocCommand.ParsePartitionKey("{\"a\":1}"));
+ }
+
+ [Fact]
+ public void DefaultStoredProcedureBody_IsValidSeedTemplate()
+ {
+ var body = SprocCommand.DefaultStoredProcedureBody();
+
+ Assert.False(string.IsNullOrWhiteSpace(body));
+ Assert.Contains("function sample", body);
+ Assert.Contains("getContext().getCollection()", body);
+ Assert.Contains("queryDocuments", body);
+ }
+}
diff --git a/CosmosDBShell.Tests/Integration/ControlFlowTests.cs b/CosmosDBShell.Tests/Integration/ControlFlowTests.cs
index af851b8..49d3eac 100644
--- a/CosmosDBShell.Tests/Integration/ControlFlowTests.cs
+++ b/CosmosDBShell.Tests/Integration/ControlFlowTests.cs
@@ -75,4 +75,39 @@ public async Task DoWhile_ExecutesAtLeastOnce()
var n = Assert.IsType(i);
Assert.Equal(1, n.Value);
}
-}
+
+ [Fact]
+ public async Task IfElse_CommandExpressionCondition_TrueBranch()
+ {
+ var state = await RunScriptAsync("if (echo \"true\") { $x = 1 } else { $x = 2 }");
+
+ Assert.False(state.IsError, FormatError(state));
+ var x = GetVariable("x");
+ var n = Assert.IsType(x);
+ Assert.Equal(1, n.Value);
+ }
+
+ [Fact]
+ public async Task IfElse_CommandExpressionCondition_FalseBranch()
+ {
+ var state = await RunScriptAsync("if (echo \"false\") { $x = 1 } else { $x = 2 }");
+
+ Assert.False(state.IsError, FormatError(state));
+ var x = GetVariable("x");
+ var n = Assert.IsType(x);
+ Assert.Equal(2, n.Value);
+ }
+
+ [Fact]
+ public async Task CommandStatement_DoesNotInheritIsPrintedFromPriorCommand()
+ {
+ // A prior command that already printed its own output sets IsPrinted on the shared
+ // state. The following command's output must still be printed and not suppressed.
+ var outputFile = CaptureOutputFile();
+ var state = await RunScriptAsync("{ version\necho \"AFTER\" }");
+
+ Assert.False(state.IsError, FormatError(state));
+ var output = await ReadRedirectAsync(outputFile);
+ Assert.Contains("AFTER", output, StringComparison.Ordinal);
+ }
+}
\ No newline at end of file
diff --git a/CosmosDBShell.Tests/ToolOperationsTests.cs b/CosmosDBShell.Tests/ToolOperationsTests.cs
index 2570011..c265b7f 100644
--- a/CosmosDBShell.Tests/ToolOperationsTests.cs
+++ b/CosmosDBShell.Tests/ToolOperationsTests.cs
@@ -105,6 +105,21 @@ public void GetTool_AppendsUserOnlyWarningForRestrictedCommands()
Assert.Contains("cannot be invoked through MCP", tool.Description, StringComparison.OrdinalIgnoreCase);
}
+ [Fact]
+ public void GetTool_MarksStoredProceduresRestrictedForMcp()
+ {
+ var factory = new CommandRunner().Commands["sproc"];
+
+ Assert.True(factory.McpRestricted);
+
+ var tool = ToolOperations.GetTool(factory);
+
+ Assert.Contains("cannot be invoked through MCP", tool.Description, StringComparison.OrdinalIgnoreCase);
+ Assert.NotNull(tool.Annotations);
+ Assert.True(tool.Annotations!.DestructiveHint);
+ Assert.True(tool.Annotations.OpenWorldHint);
+ }
+
[Fact]
public void GetTool_DoesNotAppendWarningForUnrestrictedCommands()
{
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs
new file mode 100644
index 0000000..45d4961
--- /dev/null
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs
@@ -0,0 +1,619 @@
+//------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//------------------------------------------------------------
+
+namespace Azure.Data.Cosmos.Shell.Commands;
+
+using System.Net;
+using System.Text.Json;
+using Azure.Data.Cosmos.Shell.Mcp;
+using Azure.Data.Cosmos.Shell.Parser;
+using Azure.Data.Cosmos.Shell.Util;
+using global::Azure.Data.Cosmos.Shell.Core;
+using global::Azure.Data.Cosmos.Shell.States;
+using Microsoft.Azure.Cosmos.Scripts;
+using Spectre.Console;
+
+[CosmosCommand("sproc")]
+[CosmosExample("sproc list", Description = "List the stored procedures in the current container")]
+[CosmosExample("sproc show myProc", Description = "Display the body of a stored procedure")]
+[CosmosExample("sproc exists myProc", Description = "Check whether a stored procedure exists (usable in if conditions)")]
+[CosmosExample("sproc create myProc ./myProc.js", Description = "Create a stored procedure from a JavaScript file")]
+[CosmosExample("sproc create myProc ./myProc.js --force", Description = "Create or replace a stored procedure")]
+[CosmosExample("sproc edit myProc", Description = "Edit a stored procedure body in an external editor")]
+[CosmosExample("sproc exec myProc '[\"param1\", \"param2\"]' --partition-key pk1", Description = "Execute a stored procedure with parameters")]
+[CosmosExample("sproc delete myProc", Description = "Delete a stored procedure")]
+#pragma warning disable SA1118 // Parameter should not span multiple lines
+[McpAnnotation(
+ Title = "Stored Procedures",
+ Description = @"
+Manages JavaScript stored procedures on the current Cosmos DB container through subcommands:
+- 'list' returns the stored procedure ids in the container.
+- 'show ' returns the body of a stored procedure.
+- 'exists ' returns whether a stored procedure exists.
+- 'create ' creates a stored procedure from a JavaScript file. Pass --force to replace an existing one.
+- 'exec [params]' executes a stored procedure. 'params' is a JSON array of arguments and --partition-key selects the target partition.
+- 'edit ' opens an existing stored procedure body in an external editor.
+- 'delete ' removes a stored procedure.
+This command is restricted in MCP. Run it manually in the shell.
+",
+ Restricted = true,
+ Destructive = true,
+ OpenWorld = true)]
+#pragma warning restore SA1118 // Parameter should not span multiple lines
+internal class SprocCommand : CosmosCommand
+{
+ ///
+ /// When the launched editor returns faster than this, assume it handed the
+ /// file to a background instance (for example Windows notepad or 'code'
+ /// without --wait) and prompt the user before reading the file back.
+ ///
+ private static readonly TimeSpan QuickEditorExit = TimeSpan.FromSeconds(2);
+
+ [CosmosParameter("subcommand", RequiredErrorKey = "command-sproc-error-missing_subcommand")]
+ public string Subcommand { get; init; } = string.Empty;
+
+ [CosmosParameter("name", IsRequired = false)]
+ public string? Name { get; init; }
+
+ [CosmosParameter("value", IsRequired = false)]
+ public string? Value { get; init; }
+
+ [CosmosOption("partition-key", "pk")]
+ public string? PartitionKey { get; init; }
+
+ [CosmosOption("force", "f")]
+ public bool? Force { get; init; }
+
+ [CosmosOption("database", "db")]
+ public string? Database { get; init; }
+
+ [CosmosOption("container", "con")]
+ public string? Container { get; init; }
+
+ ///
+ /// Normalizes a subcommand token to its canonical lower-case form.
+ ///
+ internal static string NormalizeSubcommand(string? value) => (value ?? string.Empty).Trim().ToLowerInvariant();
+
+ ///
+ /// Parses the JSON array of stored procedure arguments. Returns an empty array
+ /// when no value is supplied. Each argument is preserved as a
+ /// so the Cosmos SDK serializes it with the correct type.
+ ///
+ internal static object[] ParseExecParams(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ JsonDocument document;
+ try
+ {
+ document = JsonDocument.Parse(json);
+ }
+ catch (JsonException ex)
+ {
+ throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_params"), ex);
+ }
+
+ using (document)
+ {
+ if (document.RootElement.ValueKind != JsonValueKind.Array)
+ {
+ throw new CommandException("sproc", MessageService.GetString("command-sproc-error-invalid_params"));
+ }
+
+ var parameters = new List