diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94eaec0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: ci + +# Drives CI on PR raise/update and on merge to main (the long-lived feature +# branch flow: open a PR from feature/* into main, CI gates the PR, merge runs +# the same checks on main). +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: format gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Restore + run: dotnet restore StackQL.Mcp.sln + - name: dotnet format (verify no changes) + run: dotnet format StackQL.Mcp.sln --verify-no-changes --no-restore + + build-test: + name: build & test (${{ matrix.os }}) + needs: format + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Resolve StackQL bundle platform key + id: platform + shell: bash + run: | + case "${{ matrix.os }}" in + ubuntu-latest) key="linux-x64" ;; + windows-latest) key="windows-x64" ;; + macos-latest) key="darwin-universal" ;; + esac + echo "key=$key" >> "$GITHUB_OUTPUT" + + - name: Download StackQL bundle (activates conformance tests) + id: bundle + shell: bash + env: + STACKQL_VERSION: "0.10.500" + run: | + key="${{ steps.platform.outputs.key }}" + asset="stackql-mcp-${key}.mcpb" + url="https://github.com/stackql/stackql/releases/download/v${STACKQL_VERSION}/${asset}" + dest="${RUNNER_TEMP}/${asset}" + echo "Downloading $url" + if curl -fsSL "$url" -o "$dest"; then + echo "path=$dest" >> "$GITHUB_OUTPUT" + echo "Downloaded bundle to $dest" + else + echo "Bundle download failed; conformance tests will skip." + fi + + - name: Restore + run: dotnet restore StackQL.Mcp.sln + + - name: Build + run: dotnet build StackQL.Mcp.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true + + - name: Test (unit + conformance against github null_auth) + shell: bash + env: + STACKQL_MCP_BUNDLE: ${{ steps.bundle.outputs.path }} + run: dotnet test StackQL.Mcp.sln -c Release --no-build --verbosity normal + + pack: + name: pack (validate NuGet output) + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + - name: Pack (no publish) + run: | + dotnet pack src/StackQL.Mcp/StackQL.Mcp.csproj -c Release -o ./artifacts -p:ContinuousIntegrationBuild=true + dotnet pack src/StackQL.Mcp.AgentFramework/StackQL.Mcp.AgentFramework.csproj -c Release -o ./artifacts -p:ContinuousIntegrationBuild=true + - uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ./artifacts/*.nupkg diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..805fb2e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,60 @@ +name: publish + +# Builds, validates, and publishes the NuGet packages on a version tag (vX.Y.Z). +# Publishing requires the NUGET_API_KEY secret; the family stance is manual/keyed +# publish, so this runs only on explicit tags, never on push to a branch. +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: nuget + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore + run: dotnet restore StackQL.Mcp.sln + + - name: Build + run: dotnet build StackQL.Mcp.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true + + - name: Test (unit) + run: dotnet test StackQL.Mcp.sln -c Release --no-build + + - name: Pack + run: | + version="${GITHUB_REF_NAME#v}" + echo "Packing version $version" + dotnet pack src/StackQL.Mcp/StackQL.Mcp.csproj -c Release -o ./artifacts \ + -p:Version="$version" -p:ContinuousIntegrationBuild=true + dotnet pack src/StackQL.Mcp.AgentFramework/StackQL.Mcp.AgentFramework.csproj -c Release -o ./artifacts \ + -p:Version="$version" -p:ContinuousIntegrationBuild=true + + - name: Push to NuGet + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "NUGET_API_KEY not set; skipping push (packages are in artifacts)." + exit 0 + fi + dotnet nuget push "./artifacts/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ./artifacts/*.nupkg diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ede9fb8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,190 @@ +# CLAUDE.md - stackql-mcp-dotnet (embedded StackQL MCP server for .NET) + +## What this project is + +The .NET member of the StackQL embedded-MCP family: a NuGet library that gives +.NET agentic apps an embedded StackQL MCP server (cloud queries and provisioning +over SQL across AWS, Azure, Google, GitHub, Databricks, and 40+ providers). +Target repo: `stackql/stackql-mcp-dotnet` (public). NuGet package id: +`StackQL.Mcp` (verify availability before first publish; fallback +`StackQL.Mcp.Server`). Publishing to NuGet is manual (API key / 2FA), +consistent with the npm/PyPI/crates stance across the family. + +### Why this target, and why now (read this - it shapes the design) + +Microsoft shipped **Agent Framework 1.0 GA on 2026-04-03** - the production merge +of Semantic Kernel + AutoGen into one .NET (and Python) agent SDK, with +FIRST-CLASS MCP support built in (agents discover and invoke MCP servers over +stdio natively). Semantic Kernel and AutoGen are now in maintenance mode, so the +.NET agent community is actively migrating onto Agent Framework this quarter. +Named production adopters include KPMG (audit automation) and BMW. This is an +enterprise, Azure-leaning, governance-aware audience that maps directly onto the +StackQL "approvable MCP server" trust story - and almost nobody in the +MCP-server crowd is serving it. Best-timed target in the family. + +Design consequence: **the framework already consumes any stdio MCP server**, so +the minimum-viable .NET play is doc + demo (wire StackQL into Agent Framework +using the existing signed binary). This library is the POLISH layer on top - a +native builder, single-file vendoring, and a connector helper that turns the +embedded server into the framework's tool abstraction in one call. Build the +demo path first to validate the integration, then the library ergonomics. + +## Acquisition modes (one API, two modes - mirror the family) + +1. Sidecar (default): download the platform's `.mcpb` at first run, verify + sha256 against pins baked into the package, cache, spawn over stdio +2. Vendored: embed the bundle as a build resource so `dotnet publish` with + PublishSingleFile produces a self-contained executable carrying the binary + (the single-artifact story - .NET belongs in the EMBEDDED tier, not the + sidecar/interpreted tier, because of this) + +## The embedding contract (do NOT deviate - shared across the family) + +Source of truth: stackql/stackql-mcpb-packaging (the packaging repo). + +- Binaries come from the release `.mcpb` bundles; per-version sha256 pins are + published as the release's `.sha256` assets (a consolidated platforms.json + release asset is planned - prefer it once present) +- Canonical launch args (cwd-independence is MANDATORY; MCP hosts may launch + with cwd `/`, read-only on macOS - this exact bug was found and fixed in + Claude Desktop June 2026): + `mcp --mcp.server.type=stdio --approot /.stackql + --mcp.config {"server": {"mode": "", "audit": {"disabled": true}}}` +- Default mode: `read_only`. Escalation to safe/delete_safe/full_access is an + explicit caller opt-in, never a default +- Shared binary cache: `~/.stackql/mcp-server-bin///` + (the SAME path the npm/pypi/go/rust/kotlin/swift wrappers use - check before + downloading/extracting; cross-runtime cache reuse is a feature) +- Platform keys: linux-x64, linux-arm64, windows-x64, darwin-universal +- Env overrides honored: STACKQL_MCP_BIN, STACKQL_MCP_BUNDLE +- Conformance: the packaging repo's scripts/smoke-test.py `--cmd` mode must pass + against any launcher this package produces; port the same checks as an xUnit + integration test (initialize -> tools/list contains pull_provider/ + list_services/list_providers -> pull github (null_auth) -> list_services + returns real services) + +## Public API surface (target shape - match the family's builder idiom) + +Mirror the Rust/Kotlin builder feel, idiomatic C# (async, IAsyncDisposable): + +```csharp +using StackQL.Mcp; + +await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) // default; explicit here for clarity + .WithAuth("github", "null_auth") + .StartAsync(); + +var tools = await server.ListToolsAsync(); +Console.WriteLine($"{tools.Count} tools available"); + +var result = await server.CallToolAsync("list_services", + new() { ["provider"] = "github", ["row_limit"] = 5 }); +Console.WriteLine(result.Text); +``` + +Types: +- `StackqlMcp` (static `CreateBuilder()`), `StackqlMcpBuilder` + (`.WithMode/.WithAuth/.WithBinary/.WithBundlePath/.WithApproot/.WithCommand/.StartAsync`) +- `StackqlMode` enum: `ReadOnly` (default), `Safe`, `DeleteSafe`, `FullAccess` +- `StackqlServer : IAsyncDisposable` with `ListToolsAsync`, `CallToolAsync`, + and a `Client`/transport handle exposing the official C# MCP SDK client for + advanced use +- `StackqlServer.ResolveCommand(...)` static helper that returns the argv an + external harness (or Agent Framework's own MCP client) should spawn - so + users who prefer to let the framework own the process can still get the + canonical args from us + +Build on the official C# MCP SDK (the `ModelContextProtocol` package, +Microsoft + Anthropic). Keep dependencies minimal: the MCP SDK + a zip reader +(System.IO.Compression is in-box) + sha (System.Security.Cryptography in-box). +HTTP via HttpClient (in-box). Aim for ZERO third-party deps beyond the MCP SDK. + +### Agent Framework connector helper (the headline feature) + +Provide a one-call bridge so the migrating Agent Framework crowd gets StackQL +tools into an agent with no MCP plumbing: + +```csharp +// returns the StackQL tools as Agent Framework / Microsoft.Extensions.AI tools +var tools = await server.AsAgentToolsAsync(); +// or a convenience that builds an AIAgent wired to StackQL: +// StackqlAgent.Create(chatClient, mode: ReadOnly, auth: ...) +``` + +Research the exact Agent Framework tool/IChatClient tool-registration surface at +build time (it is new and moving) and match it. If a clean adapter is not yet +stable, ship `ResolveCommand` + a documented snippet wiring StackQL via the +framework's native MCP-stdio support, and add the adapter when the API settles. +Do not block the library on an unstable adapter. + +## Demo app: `driftwatch` - cloud drift sentinel for .NET shops + +Business use case: platform teams want to know the instant real cloud state +drifts from intended state - new public exposure, untagged spend, config that +no longer matches policy - posted where they already work (Teams), on a +schedule, with zero console-clicking. + +Shape (pick ONE host for v1 - a Worker Service is the cleanest demo; an Azure +Function variant is the follow-up that wins the Azure crowd): + +1. A .NET Worker Service (Generic Host, `dotnet publish` single-file) embedding + the StackQL server (vendored) + an Agent Framework agent (Claude or Azure + OpenAI via the framework's connector) +2. On a schedule, the agent runs a `read_only` drift suite expressed as SQL + checks: newly public storage, security groups open to 0.0.0.0/0, resources + missing required tags, drift from a declared baseline (start with a simple + baseline.yaml of expected resources/config) +3. Findings -> a Teams Adaptive Card (incoming webhook) summarising what + changed since the last run, EACH finding showing the SQL that produced it + (inspectability is the brand; it is also the audit trail KPMG-style buyers + want) +4. Azure-first provider coverage for the demo (where the .NET audience lives), + with the github null_auth provider as the no-credentials CI/test fixture + (org posture: repos without branch protection, etc.) + +driftwatch doubles as: a Microsoft Agent Framework reference sample, a .NET +user-group talk ("MCP-connected cloud agents in Agent Framework"), and a +concrete artifact for the #7 enterprise-trust narrative (read_only + audit + +signed binary in a governance-conscious framework). + +## Build, test, CI + +- .NET 8 LTS baseline (also target net9.0 if trivial); C# latest; nullable + enabled; `dotnet format` clean as a CI gate +- Solution layout: `src/StackQL.Mcp/` (library), `src/StackQL.Mcp.AgentFramework/` + (the connector helper - separate package so the core lib has no Agent + Framework dep), `samples/driftwatch/`, `tests/` +- Tests: xUnit - unit (pin parse / cache path / launch-arg construction), + integration (spawn + initialize + tools/list against the github null_auth + fixture - the family conformance check), and a `Vendored` build test that + publishes single-file and runs it. CI matrix: ubuntu + windows + macos on + GitHub Actions; `dotnet pack` produces the NuGet on tag +- Reuse the packaging repo's scripts/smoke-test.py via the `--cmd` mode in CI + to assert cross-implementation parity +- Source Link + deterministic build + embedded PDBs (enterprise audiences + check these); sign the NuGet if/when a cert is available (ties to #7) + +## Milestones + +1. Library core (sidecar mode) + conformance xUnit green on 3 OSes; NuGet id + reserved; minimal README quick-start +2. Vendored single-file path working; Agent Framework wiring proven in a + throwaway sample (validates the integration before polishing the adapter) +3. driftwatch demo (Worker Service) against Azure + the github fixture, with a + recorded run and the Teams card; the AsAgentToolsAsync adapter if the + framework API is stable enough +4. Publish StackQL.Mcp (+ .AgentFramework) to NuGet (manual), docs polish, + announce: dev.to + the Microsoft Agent Framework GitHub discussions + a + Melbourne/Sydney .NET user-group talk; cross-link from stackql.io docs + +## Conventions (house style - match the family) + +- Plain hyphens only (no em dashes); ASCII arrows `->`; no unicode bullets/arrows +- Matter-of-fact tone in all docs and comments; no hyperbole, no sycophancy +- Stderr/ILogger for diagnostics; stdout belongs to the MCP protocol +- MIT license; mcp-name reference: io.github.stackql/stackql-mcp +- Verify-don't-assume for moving APIs: the official C# MCP SDK and Microsoft + Agent Framework are both young (Agent Framework 1.0 is from April 2026) - + check their current package names, versions, and tool-registration surfaces + against live docs at build time rather than from memory diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..bf55fd3 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,40 @@ + + + + + latest + enable + enable + true + + $(NoWarn);CS1573 + + + + + true + true + embedded + false + + + + StackQL Studios + StackQL Studios + MIT + https://github.com/stackql/stackql-mcp-dotnet + https://github.com/stackql/stackql-mcp-dotnet + git + true + Copyright (c) 2026 StackQL Studios + + + + + + + diff --git a/README.md b/README.md index 7d7e98b..ba80995 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,144 @@ # stackql-mcp-dotnet -embedded StackQL MCP server for .NET + +Embedded StackQL MCP server for .NET. A NuGet library that gives .NET agentic +apps an in-process StackQL MCP server: query and provision cloud and SaaS +resources with SQL across AWS, Azure, Google, GitHub, Databricks and 40+ +providers, over stdio, read-only by default. + +The .NET member of the StackQL embedded-MCP family. Microsoft Agent Framework +1.0 consumes any stdio MCP server natively, so the minimum integration is just +the canonical launch args; this library is the polish layer on top - a native +builder, single-file vendoring, and a one-call bridge into Agent Framework's +tool abstraction. + +## Packages + +| Package | What it is | +| --- | --- | +| `StackQL.Mcp` | Core library: builder, embedded server, sidecar/vendored acquisition. Zero third-party deps beyond the official C# MCP SDK. | +| `StackQL.Mcp.AgentFramework` | One-call bridge that turns the StackQL tools into Microsoft Agent Framework / `Microsoft.Extensions.AI` tools. Separate package so the core has no Agent Framework dependency. | + +## Quick start + +```csharp +using StackQL.Mcp; + +await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) // default; explicit here for clarity + .WithAuth("github", "null_auth") // no credentials needed for github + .StartAsync(); + +var tools = await server.ListToolsAsync(); +Console.WriteLine($"{tools.Count} tools available"); + +var result = await server.CallToolAsync("list_services", + new() { ["provider"] = "github", ["row_limit"] = 5 }); +Console.WriteLine(result.Text); +``` + +### Modes + +`StackqlMode` controls what the server is allowed to do. The default is +`ReadOnly`; escalation is always an explicit opt-in, never a default. + +| Mode | Effect | +| --- | --- | +| `ReadOnly` | Queries only (default). | +| `Safe` | Creates and updates, no deletes. | +| `DeleteSafe` | Creates, updates, and deletes the server classes as safe. | +| `FullAccess` | All operations, including unrestricted deletes. | + +## Microsoft Agent Framework + +`McpClientTool` (what the MCP SDK returns) derives from `AIFunction`, which is +the exact tool abstraction an Agent Framework agent consumes. So the bridge is a +thin, stable adapter: + +```csharp +using StackQL.Mcp; +using StackQL.Mcp.AgentFramework; + +await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) + .WithAuth("github", "null_auth") + .StartAsync(); + +var tools = await server.AsAgentToolsAsync(); + +// chatClient is any IChatClient (Azure OpenAI, Anthropic, etc.) +AIAgent agent = chatClient.CreateAIAgent( + instructions: "You answer cloud-posture questions using StackQL.", + tools: tools.ToArray()); + +var reply = await agent.RunAsync("Which GitHub repos lack a license?"); +``` + +Prefer to let Agent Framework own the StackQL process via its native +MCP-over-stdio support? Get the canonical, cwd-independent argv from us and +register it with the framework's own MCP client: + +```csharp +var argv = await StackqlServer.ResolveCommandAsync(StackqlMode.ReadOnly); +// argv[0] is the executable; argv[1..] are the arguments. +``` + +## Acquisition modes + +One API, two modes, mirroring the rest of the family. + +1. **Sidecar (default).** On first run the platform's `.mcpb` bundle is + downloaded, verified by sha256 against pins baked into the package, extracted + into the shared cache, and spawned over stdio. +2. **Vendored.** Embed the bundle as a build resource so + `dotnet publish -p:PublishSingleFile=true` produces a self-contained + executable carrying the binary - the single-artifact story: + + ```bash + dotnet build src/StackQL.Mcp/StackQL.Mcp.csproj \ + -p:StackqlVendorBundle=/path/to/stackql-mcp-windows-x64.mcpb + ``` + +The binary cache is shared with the npm/pypi/go/rust/kotlin/swift wrappers at +`~/.stackql/mcp-server-bin///`, so a binary fetched by any +one runtime is reused by the others. + +### Overrides + +| Knob | Effect | +| --- | --- | +| `WithBinary(path)` / `STACKQL_MCP_BIN` | Use an explicit binary, skip acquisition. | +| `WithBundlePath(path)` / `STACKQL_MCP_BUNDLE` | Use an explicit `.mcpb`, skip download. | +| `WithApproot(dir)` | Override the approot (default `~/.stackql`). | +| `WithCommand(argv)` | Take full control of the launch command. | + +> The pinned sha256 values in `src/StackQL.Mcp/pins.json` ship as placeholders +> until populated from a release's `.sha256` assets. Until then, set +> `STACKQL_MCP_BIN` or `STACKQL_MCP_BUNDLE` for local development; an +> unconfigured sidecar download fails the integrity check on purpose rather than +> running an unverified binary. + +## Sample: driftwatch + +[`samples/driftwatch`](samples/driftwatch) is a .NET Worker Service that embeds +the read-only StackQL server and, on a schedule, runs a suite of SQL drift +checks (public exposure, missing tags/licenses, baseline drift) and posts the +findings to a Teams Adaptive Card - each finding showing the exact SQL that +produced it. It runs against the github `null_auth` provider with zero +credentials, so it works out of the box. + +## Build and test + +```bash +dotnet build StackQL.Mcp.sln -c Release +dotnet test StackQL.Mcp.sln -c Release +``` + +Unit tests (pin parse, cache path, launch-arg construction) run everywhere. The +conformance integration test (initialize -> tools/list -> pull github -> list +services) runs when a StackQL binary is available via `STACKQL_MCP_BUNDLE` / +`STACKQL_MCP_BIN`; CI downloads the platform bundle to activate it across Linux, +Windows, and macOS. + +## License + +MIT. mcp-name reference: `io.github.stackql/stackql-mcp`. diff --git a/StackQL.Mcp.sln b/StackQL.Mcp.sln new file mode 100644 index 0000000..db2a2d3 --- /dev/null +++ b/StackQL.Mcp.sln @@ -0,0 +1,52 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{35CF2091-37AB-4204-9C8F-3E12E69E2904}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackQL.Mcp", "src\StackQL.Mcp\StackQL.Mcp.csproj", "{23AFD1A9-5E38-4CB7-83FD-1E5685C6405C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackQL.Mcp.AgentFramework", "src\StackQL.Mcp.AgentFramework\StackQL.Mcp.AgentFramework.csproj", "{CB3B1C88-6394-462D-8C2B-679D3746238E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D5E408F1-5C67-4E4A-B150-B154B012A459}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackQL.Mcp.Tests", "tests\StackQL.Mcp.Tests\StackQL.Mcp.Tests.csproj", "{A4726EA0-D943-4286-BD48-A62BC3C7EA62}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{7FBADA8F-3835-42EA-8E95-D9E8CF06EB0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "driftwatch", "samples\driftwatch\driftwatch.csproj", "{381EBBBC-3805-47EC-A48D-9E87EE8DB43C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {23AFD1A9-5E38-4CB7-83FD-1E5685C6405C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23AFD1A9-5E38-4CB7-83FD-1E5685C6405C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23AFD1A9-5E38-4CB7-83FD-1E5685C6405C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23AFD1A9-5E38-4CB7-83FD-1E5685C6405C}.Release|Any CPU.Build.0 = Release|Any CPU + {CB3B1C88-6394-462D-8C2B-679D3746238E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB3B1C88-6394-462D-8C2B-679D3746238E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB3B1C88-6394-462D-8C2B-679D3746238E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB3B1C88-6394-462D-8C2B-679D3746238E}.Release|Any CPU.Build.0 = Release|Any CPU + {A4726EA0-D943-4286-BD48-A62BC3C7EA62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4726EA0-D943-4286-BD48-A62BC3C7EA62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4726EA0-D943-4286-BD48-A62BC3C7EA62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4726EA0-D943-4286-BD48-A62BC3C7EA62}.Release|Any CPU.Build.0 = Release|Any CPU + {381EBBBC-3805-47EC-A48D-9E87EE8DB43C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {381EBBBC-3805-47EC-A48D-9E87EE8DB43C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {381EBBBC-3805-47EC-A48D-9E87EE8DB43C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {381EBBBC-3805-47EC-A48D-9E87EE8DB43C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {23AFD1A9-5E38-4CB7-83FD-1E5685C6405C} = {35CF2091-37AB-4204-9C8F-3E12E69E2904} + {CB3B1C88-6394-462D-8C2B-679D3746238E} = {35CF2091-37AB-4204-9C8F-3E12E69E2904} + {A4726EA0-D943-4286-BD48-A62BC3C7EA62} = {D5E408F1-5C67-4E4A-B150-B154B012A459} + {381EBBBC-3805-47EC-A48D-9E87EE8DB43C} = {7FBADA8F-3835-42EA-8E95-D9E8CF06EB0B} + EndGlobalSection +EndGlobal diff --git a/samples/driftwatch/DriftCheck.cs b/samples/driftwatch/DriftCheck.cs new file mode 100644 index 0000000..da2cd22 --- /dev/null +++ b/samples/driftwatch/DriftCheck.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Driftwatch; + +/// +/// A single drift check: a named SQL query whose returned rows are findings. +/// Every row the query returns is a thing that drifted; an empty result is "clean". +/// The SQL is carried into the report so each finding shows exactly how it was +/// produced - inspectability is the whole point. +/// +public sealed class DriftCheck +{ + /// Stable id, e.g. "github-repos-without-branch-protection". + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Human title shown on the card, e.g. "Repos missing branch protection". + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// Severity label: info | warning | high. + [JsonPropertyName("severity")] + public string Severity { get; set; } = "warning"; + + /// The StackQL SELECT that produces findings (one finding per row). + [JsonPropertyName("sql")] + public string Sql { get; set; } = string.Empty; +} + +/// The outcome of running one . +public sealed class DriftResult +{ + public required DriftCheck Check { get; init; } + + /// Findings (rows). Empty means the check passed. + public IReadOnlyList> Findings { get; init; } = + Array.Empty>(); + + /// Set when the check could not run (e.g. provider/query error). + public string? Error { get; init; } + + public bool HasFindings => Findings.Count > 0; + public bool Failed => Error is not null; +} diff --git a/samples/driftwatch/DriftEngine.cs b/samples/driftwatch/DriftEngine.cs new file mode 100644 index 0000000..a339f4d --- /dev/null +++ b/samples/driftwatch/DriftEngine.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StackQL.Mcp; + +namespace Driftwatch; + +/// +/// Runs a set of s against an embedded StackQL server in +/// read-only mode and collects the findings. The server is held read_only for the +/// entire run; nothing here can mutate cloud state. +/// +public sealed class DriftEngine +{ + private readonly StackqlServer _server; + private readonly ILogger _logger; + + public DriftEngine(StackqlServer server, ILogger logger) + { + _server = server; + _logger = logger; + } + + public async Task> RunAsync( + IEnumerable checks, + CancellationToken ct) + { + var results = new List(); + + foreach (var check in checks) + { + ct.ThrowIfCancellationRequested(); + results.Add(await RunOneAsync(check, ct)); + } + + return results; + } + + private async Task RunOneAsync(DriftCheck check, CancellationToken ct) + { + _logger.LogInformation("Running drift check {Id}", check.Id); + + try + { + var result = await _server.CallToolAsync( + "run_select_query", + new Dictionary { ["sql"] = check.Sql }, + ct); + + if (result.IsError) + { + return new DriftResult { Check = check, Error = result.Text }; + } + + var rows = ParseRows(result.Text); + return new DriftResult { Check = check, Findings = rows }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Drift check {Id} failed", check.Id); + return new DriftResult { Check = check, Error = ex.Message }; + } + } + + /// + /// The run_select_query tool returns text containing a JSON object with a + /// "rows" array. Parse defensively: the text may be the JSON itself or wrap it. + /// + private static IReadOnlyList> ParseRows(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return Array.Empty>(); + } + + var json = ExtractJsonObject(text); + if (json is null) + { + return Array.Empty>(); + } + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("rows", out var rowsEl) + || rowsEl.ValueKind != JsonValueKind.Array) + { + return Array.Empty>(); + } + + var rows = new List>(); + foreach (var rowEl in rowsEl.EnumerateArray()) + { + if (rowEl.ValueKind != JsonValueKind.Object) + { + continue; + } + + var row = new Dictionary(StringComparer.Ordinal); + foreach (var prop in rowEl.EnumerateObject()) + { + row[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => prop.Value.GetRawText(), + }; + } + + rows.Add(row); + } + + return rows; + } + + private static string? ExtractJsonObject(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start < 0 || end <= start) + { + return null; + } + + return text.Substring(start, end - start + 1); + } +} diff --git a/samples/driftwatch/DriftWorker.cs b/samples/driftwatch/DriftWorker.cs new file mode 100644 index 0000000..5c7c3c8 --- /dev/null +++ b/samples/driftwatch/DriftWorker.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackQL.Mcp; + +namespace Driftwatch; + +public sealed class DriftOptions +{ + public const string SectionName = "Driftwatch"; + + /// How often to run the drift suite. + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(6); + + /// Run once immediately on startup before the first interval wait. + public bool RunOnStartup { get; set; } = true; + + /// Teams incoming-webhook URL. When empty, findings are logged. + public string? TeamsWebhookUrl { get; set; } + + /// Path to the checks file (JSON array of DriftCheck). + public string ChecksPath { get; set; } = "checks.json"; + + /// Providers to pull at startup, e.g. ["github"]. null_auth providers + /// (like github) need no credentials and make the sample runnable in CI. + public string[] Providers { get; set; } = new[] { "github" }; +} + +/// +/// The driftwatch worker: embeds a read-only StackQL server, pulls the configured +/// providers, then runs the drift suite on a schedule and reports findings. +/// +public sealed class DriftWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly DriftOptions _options; + private readonly TeamsReporter _reporter; + private readonly ILoggerFactory _loggerFactory; + + public DriftWorker( + ILogger logger, + IOptions options, + TeamsReporter reporter, + ILoggerFactory loggerFactory) + { + _logger = logger; + _options = options.Value; + _reporter = reporter; + _loggerFactory = loggerFactory; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("driftwatch starting; embedding StackQL (read_only)"); + + await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) + .WithAuth("github", "null_auth") + .StartAsync(stoppingToken); + + await PullProvidersAsync(server, stoppingToken); + + var checks = LoadChecks(); + var engine = new DriftEngine(server, _loggerFactory.CreateLogger()); + + if (_options.RunOnStartup) + { + await RunSuiteAsync(engine, checks, stoppingToken); + } + + using var timer = new PeriodicTimer(_options.Interval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunSuiteAsync(engine, checks, stoppingToken); + } + } + + private async Task PullProvidersAsync(StackqlServer server, CancellationToken ct) + { + foreach (var provider in _options.Providers) + { + _logger.LogInformation("Pulling provider {Provider}", provider); + var result = await server.CallToolAsync( + "pull_provider", + new Dictionary { ["provider"] = provider }, + ct); + + if (result.IsError) + { + _logger.LogWarning("pull_provider {Provider} failed: {Error}", provider, result.Text); + } + } + } + + private async Task RunSuiteAsync(DriftEngine engine, IReadOnlyList checks, CancellationToken ct) + { + try + { + var results = await engine.RunAsync(checks, ct); + await _reporter.ReportAsync(results, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Drift suite run failed"); + } + } + + private IReadOnlyList LoadChecks() + { + var path = _options.ChecksPath; + if (!File.Exists(path)) + { + _logger.LogWarning("Checks file {Path} not found; no checks will run", path); + return Array.Empty(); + } + + var json = File.ReadAllText(path); + var checks = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }) ?? new List(); + + _logger.LogInformation("Loaded {Count} drift checks from {Path}", checks.Count, path); + return checks; + } +} diff --git a/samples/driftwatch/Program.cs b/samples/driftwatch/Program.cs new file mode 100644 index 0000000..f97353c --- /dev/null +++ b/samples/driftwatch/Program.cs @@ -0,0 +1,33 @@ +using System.Net.Http; +using Driftwatch; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure( + builder.Configuration.GetSection(DriftOptions.SectionName)); + +// Allow the Teams webhook to come from an env var without putting it in config. +var envWebhook = Environment.GetEnvironmentVariable("DRIFTWATCH_TEAMS_WEBHOOK"); +if (!string.IsNullOrWhiteSpace(envWebhook)) +{ + builder.Services.PostConfigure(o => o.TeamsWebhookUrl = envWebhook); +} + +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(sp => +{ + var httpFactory = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + return new TeamsReporter( + httpFactory.CreateClient("teams"), + sp.GetRequiredService>(), + opts.TeamsWebhookUrl); +}); + +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/samples/driftwatch/README.md b/samples/driftwatch/README.md new file mode 100644 index 0000000..c1adf75 --- /dev/null +++ b/samples/driftwatch/README.md @@ -0,0 +1,61 @@ +# driftwatch + +A cloud drift sentinel for .NET shops, built on the embedded StackQL MCP server. + +Platform teams want to know the instant real cloud state drifts from intended +state - new public exposure, untagged spend, config that no longer matches +policy - posted where they already work (Teams), on a schedule, with zero +console-clicking. driftwatch is a .NET Worker Service that does exactly that. + +## How it works + +1. On startup it embeds a StackQL MCP server in `read_only` mode (nothing it does + can mutate cloud state) and pulls the configured providers. +2. On a schedule it runs the drift suite in [`checks.json`](checks.json): each + check is a named SQL query whose returned rows are findings. An empty result + is "clean". +3. Findings are posted to a Teams Adaptive Card via an incoming webhook, each + finding showing the exact SQL that produced it - the card doubles as an audit + trail. With no webhook configured, findings are written to the log, so the + sample runs end to end with zero external setup. + +The default checks run against the github `null_auth` provider, which needs no +credentials. Point [`baseline.yaml`](baseline.yaml) and `checks.json` at Azure (or +any of the 40+ providers) for real coverage. + +## Run it + +```bash +dotnet run --project samples/driftwatch +``` + +Configuration lives in [`appsettings.json`](appsettings.json) under the +`Driftwatch` section: + +| Setting | Meaning | +| --- | --- | +| `Interval` | How often to run the suite (e.g. `06:00:00`). | +| `RunOnStartup` | Run once immediately before the first interval wait. | +| `TeamsWebhookUrl` | Teams incoming-webhook URL. Empty = log findings. | +| `ChecksPath` | Path to the checks file. | +| `Providers` | Providers to pull at startup. | + +The Teams webhook can also be supplied out-of-band via the +`DRIFTWATCH_TEAMS_WEBHOOK` environment variable so it stays out of config. + +## Single-file publish + +The project is set up for a self-contained single-file publish. Vendor the +StackQL bundle so the published executable carries the binary: + +```bash +dotnet publish samples/driftwatch -c Release -r win-x64 \ + -p:StackqlVendorBundle=/path/to/stackql-mcp-windows-x64.mcpb +``` + +## Adding an Agent Framework agent + +This v1 runs the drift SQL directly, so it needs no LLM credentials. To turn the +suite into an agent that explains findings in natural language, add the +`StackQL.Mcp.AgentFramework` package and wire the StackQL tools into an +`AIAgent` with `server.AsAgentToolsAsync()` - see the repo README. diff --git a/samples/driftwatch/TeamsReporter.cs b/samples/driftwatch/TeamsReporter.cs new file mode 100644 index 0000000..e733f10 --- /dev/null +++ b/samples/driftwatch/TeamsReporter.cs @@ -0,0 +1,171 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Driftwatch; + +/// +/// Posts a drift report as a Teams Adaptive Card to an incoming-webhook URL. Each +/// finding renders the SQL that produced it, so the card doubles as an audit trail. +/// When no webhook is configured, the report is written to the log instead, so the +/// sample runs end-to-end with zero external setup. +/// +public sealed class TeamsReporter +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly string? _webhookUrl; + + public TeamsReporter(HttpClient http, ILogger logger, string? webhookUrl) + { + _http = http; + _logger = logger; + _webhookUrl = webhookUrl; + } + + public async Task ReportAsync(IReadOnlyList results, CancellationToken ct) + { + var withFindings = results.Where(r => r.HasFindings).ToList(); + var failed = results.Where(r => r.Failed).ToList(); + + if (string.IsNullOrWhiteSpace(_webhookUrl)) + { + LogReport(results, withFindings, failed); + return; + } + + var card = BuildCard(results, withFindings, failed); + using var content = new StringContent(card, Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync(_webhookUrl, content, ct); + + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Teams webhook returned {Status}: {Body}", resp.StatusCode, body); + } + else + { + _logger.LogInformation( + "Posted drift card: {Findings} checks with findings, {Failed} failed", + withFindings.Count, failed.Count); + } + } + + private void LogReport( + IReadOnlyList all, + IReadOnlyList withFindings, + IReadOnlyList failed) + { + _logger.LogInformation( + "Drift report: {Total} checks, {Clean} clean, {Drift} with findings, {Failed} failed", + all.Count, all.Count - withFindings.Count - failed.Count, withFindings.Count, failed.Count); + + foreach (var r in withFindings) + { + _logger.LogWarning( + "[{Severity}] {Title}: {Count} finding(s)\n SQL: {Sql}", + r.Check.Severity, r.Check.Title, r.Findings.Count, r.Check.Sql); + } + } + + /// + /// Builds the MessageCard envelope Teams incoming webhooks accept, embedding an + /// Adaptive Card in the attachment. Hand-built so the sample carries no card SDK. + /// + private static string BuildCard( + IReadOnlyList all, + IReadOnlyList withFindings, + IReadOnlyList failed) + { + var clean = all.Count - withFindings.Count - failed.Count; + var body = new List + { + new + { + type = "TextBlock", + size = "Large", + weight = "Bolder", + text = withFindings.Count == 0 ? "driftwatch: no drift detected" : "driftwatch: drift detected", + }, + new + { + type = "TextBlock", + isSubtle = true, + wrap = true, + text = $"{all.Count} checks - {clean} clean, {withFindings.Count} with findings, {failed.Count} errored", + }, + }; + + foreach (var r in withFindings) + { + body.Add(new + { + type = "TextBlock", + weight = "Bolder", + wrap = true, + color = SeverityColor(r.Check.Severity), + text = $"{r.Check.Title} ({r.Findings.Count})", + }); + body.Add(new + { + type = "TextBlock", + wrap = true, + isSubtle = true, + fontType = "Monospace", + text = r.Check.Sql, + }); + body.Add(new + { + type = "TextBlock", + wrap = true, + text = SummarizeFindings(r.Findings), + }); + } + + var adaptiveCard = new + { + type = "AdaptiveCard", + schema = "http://adaptivecards.io/schemas/adaptive-card.json", + version = "1.4", + body, + }; + + var envelope = new + { + type = "message", + attachments = new[] + { + new + { + contentType = "application/vnd.microsoft.card.adaptive", + content = adaptiveCard, + }, + }, + }; + + // Rename "schema" -> "$schema" which the C# anonymous type cannot express. + var json = JsonSerializer.Serialize(envelope); + return json.Replace("\"schema\":", "\"$schema\":"); + } + + private static string SeverityColor(string severity) => severity.ToLowerInvariant() switch + { + "high" => "Attention", + "warning" => "Warning", + _ => "Default", + }; + + private static string SummarizeFindings(IReadOnlyList> findings) + { + // Show up to the first 5 findings as compact key=value lines. + var lines = findings.Take(5).Select(f => + "- " + string.Join(", ", f.Select(kv => $"{kv.Key}={kv.Value}"))); + var text = string.Join("\n", lines); + if (findings.Count > 5) + { + text += $"\n- ... and {findings.Count - 5} more"; + } + + return text; + } +} diff --git a/samples/driftwatch/appsettings.json b/samples/driftwatch/appsettings.json new file mode 100644 index 0000000..3355511 --- /dev/null +++ b/samples/driftwatch/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Driftwatch": { + "Interval": "06:00:00", + "RunOnStartup": true, + "TeamsWebhookUrl": "", + "ChecksPath": "checks.json", + "Providers": [ "github" ] + } +} diff --git a/samples/driftwatch/baseline.yaml b/samples/driftwatch/baseline.yaml new file mode 100644 index 0000000..525d872 --- /dev/null +++ b/samples/driftwatch/baseline.yaml @@ -0,0 +1,24 @@ +# driftwatch baseline - the declared "intended" cloud/SaaS state. +# v1 keeps this simple: a list of expected resources and config that the drift +# checks compare reality against. Extend with Azure resources as you wire the +# Azure provider (see README). The github fixture below needs no credentials and +# is what CI runs against. + +github: + org: stackql + # Repos that are allowed to be public. Anything public and not listed is drift. + expected_public_repos: + - stackql + - stackql-mcp-dotnet + # Every repo is expected to carry a license. + require_license: true + +# Azure section is a placeholder for the Azure-first demo coverage. Populate with +# expected resource groups, required tags, and "no storage account may be public" +# style invariants once Azure credentials are wired. +azure: + required_tags: + - owner + - cost-center + # Subnets/security groups must not be open to the world. + forbid_public_ingress_cidr: "0.0.0.0/0" diff --git a/samples/driftwatch/checks.json b/samples/driftwatch/checks.json new file mode 100644 index 0000000..dd646c0 --- /dev/null +++ b/samples/driftwatch/checks.json @@ -0,0 +1,20 @@ +[ + { + "id": "github-public-repos-inventory", + "title": "Public repos in the org (inventory baseline)", + "severity": "info", + "sql": "SELECT name, visibility, default_branch FROM github.repos.repos WHERE org = 'stackql' AND visibility = 'public'" + }, + { + "id": "github-repos-without-license", + "title": "Repos missing a license", + "severity": "warning", + "sql": "SELECT name FROM github.repos.repos WHERE org = 'stackql' AND JSON_EXTRACT(license, '$.key') IS NULL" + }, + { + "id": "github-org-members-without-2fa-disabled-view", + "title": "Org members (review 2FA posture out-of-band)", + "severity": "info", + "sql": "SELECT login FROM github.orgs.members WHERE org = 'stackql'" + } +] diff --git a/samples/driftwatch/driftwatch.csproj b/samples/driftwatch/driftwatch.csproj new file mode 100644 index 0000000..a5e391e --- /dev/null +++ b/samples/driftwatch/driftwatch.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + false + Exe + driftwatch-sample + + + true + true + win-x64 + + + + + + + + + + + + + + + + + + diff --git a/src/StackQL.Mcp.AgentFramework/StackQL.Mcp.AgentFramework.csproj b/src/StackQL.Mcp.AgentFramework/StackQL.Mcp.AgentFramework.csproj new file mode 100644 index 0000000..d48e0bc --- /dev/null +++ b/src/StackQL.Mcp.AgentFramework/StackQL.Mcp.AgentFramework.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net9.0 + true + true + StackQL.Mcp.AgentFramework + StackQL MCP connector for Microsoft Agent Framework + Bridges the embedded StackQL MCP server into Microsoft Agent Framework: one call turns StackQL's tools into Agent Framework / Microsoft.Extensions.AI tools you can hand to an AIAgent. Kept as a separate package so the core StackQL.Mcp library carries no Agent Framework dependency. + stackql;mcp;agent-framework;microsoft-agents-ai;ai;tools;cloud + + + + + + + + + + + + + + + + diff --git a/src/StackQL.Mcp.AgentFramework/StackqlAgentToolsExtensions.cs b/src/StackQL.Mcp.AgentFramework/StackqlAgentToolsExtensions.cs new file mode 100644 index 0000000..ad48414 --- /dev/null +++ b/src/StackQL.Mcp.AgentFramework/StackqlAgentToolsExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +namespace StackQL.Mcp.AgentFramework; + +/// +/// Bridges an embedded StackQL MCP server into Microsoft Agent Framework. +/// +/// +/// StackQL's tools come back from the MCP SDK as , +/// which derives from (and thus ), +/// the exact abstraction Agent Framework agents consume. So the bridge is a thin, +/// stable adapter: list the tools, hand them to the agent's tools array. +/// +/// +/// Wiring StackQL into an agent (Agent Framework 1.0, Microsoft.Agents.AI): +/// +/// await using var server = await StackqlMcp.CreateBuilder() +/// .WithMode(StackqlMode.ReadOnly) +/// .WithAuth("github", "null_auth") +/// .StartAsync(); +/// +/// var tools = await server.AsAgentToolsAsync(); +/// +/// // chatClient is any IChatClient (Azure OpenAI, Anthropic, etc.) +/// AIAgent agent = chatClient.CreateAIAgent( +/// instructions: "You answer cloud-posture questions using StackQL.", +/// tools: tools.ToArray()); +/// +/// var reply = await agent.RunAsync("Which GitHub repos lack branch protection?"); +/// +/// +/// +/// If you would rather let Agent Framework own the StackQL process via its native +/// MCP-over-stdio support, get the canonical argv from +/// instead and register it as an +/// MCP server with the framework's own client. +/// +/// +public static class StackqlAgentToolsExtensions +{ + /// + /// Returns the StackQL server's tools as Agent Framework / + /// Microsoft.Extensions.AI instances, ready to pass to an + /// agent's tools collection. + /// + public static async Task> AsAgentToolsAsync( + this StackqlServer server, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(server); + + var tools = await server.ListToolsAsync(ct).ConfigureAwait(false); + // McpClientTool : AIFunction : AITool - upcast directly, no wrapping. + return tools.Cast().ToList(); + } +} diff --git a/src/StackQL.Mcp/BinaryResolver.cs b/src/StackQL.Mcp/BinaryResolver.cs new file mode 100644 index 0000000..68a26f9 --- /dev/null +++ b/src/StackQL.Mcp/BinaryResolver.cs @@ -0,0 +1,262 @@ +using System.IO.Compression; +using System.Security.Cryptography; + +namespace StackQL.Mcp; + +/// +/// Resolves the path to a ready-to-run StackQL executable, acquiring it if +/// necessary. Resolution order (first match wins): +/// +/// Explicit binary path passed to the builder (WithBinary). +/// STACKQL_MCP_BIN environment variable. +/// Already-extracted binary in the shared cache. +/// Explicit bundle (WithBundlePath) or STACKQL_MCP_BUNDLE. +/// Vendored bundle embedded in the assembly. +/// Sidecar download of the platform bundle, verified against pins. +/// +/// All extraction targets the shared cache so other runtimes reuse it. +/// +internal sealed class BinaryResolver +{ + private readonly Pins _pins; + private readonly HttpClient _http; + + public BinaryResolver(Pins pins, HttpClient http) + { + _pins = pins; + _http = http; + } + + /// Base URL for release bundles. Overridable for testing/mirrors. + public string ReleaseBaseUrl { get; init; } = + "https://github.com/stackql/stackql/releases/download"; + + /// + /// Resolves the executable path, performing acquisition if needed. + /// + /// Caller-supplied binary path, or null. + /// Caller-supplied bundle path, or null. + public async Task ResolveAsync( + string? explicitBinary, + string? explicitBundle, + CancellationToken ct) + { + // 1. Explicit binary path. + if (!string.IsNullOrWhiteSpace(explicitBinary)) + { + return RequireExisting(explicitBinary, "WithBinary path"); + } + + // 2. STACKQL_MCP_BIN override. + var envBin = Environment.GetEnvironmentVariable("STACKQL_MCP_BIN"); + if (!string.IsNullOrWhiteSpace(envBin)) + { + return RequireExisting(envBin, "STACKQL_MCP_BIN"); + } + + var platformKey = Platform.CurrentKey(); + var cacheDir = Platform.BinCacheDir(_pins.Version, platformKey); + var cachedBinary = Path.Combine(cacheDir, Platform.BinaryFileName()); + + // 3. Already extracted in the shared cache (cross-runtime reuse). + if (File.Exists(cachedBinary)) + { + return cachedBinary; + } + + // 4. Explicit bundle, then STACKQL_MCP_BUNDLE. + var bundlePath = explicitBundle + ?? Environment.GetEnvironmentVariable("STACKQL_MCP_BUNDLE"); + if (!string.IsNullOrWhiteSpace(bundlePath)) + { + var bundle = RequireExisting(bundlePath, "bundle path"); + return ExtractBundle(await File.ReadAllBytesAsync(bundle, ct), cacheDir, verifySha: false); + } + + // 5. Vendored bundle embedded in the assembly. + var vendored = TryReadVendoredBundle(); + if (vendored is not null) + { + // Vendored bytes were verified at pack time; extract without re-verify. + return ExtractBundle(vendored, cacheDir, verifySha: false); + } + + // 6. Sidecar download, verified against the pinned sha256. + var bytes = await DownloadBundleAsync(platformKey, ct); + return ExtractBundle(bytes, cacheDir, verifySha: true, platformKey: platformKey); + } + + private async Task DownloadBundleAsync(string platformKey, CancellationToken ct) + { + var asset = Pins.BundleAssetName(platformKey); + var url = $"{ReleaseBaseUrl}/v{_pins.Version}/{asset}"; + + using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsByteArrayAsync(ct); + } + + /// + /// Verifies (optionally), extracts the bundle into , + /// and returns the executable path. Extraction is atomic-ish: write to a temp + /// sibling dir then move into place, so a concurrent runtime never sees a + /// half-extracted cache. + /// + private string ExtractBundle( + byte[] bundleBytes, + string cacheDir, + bool verifySha, + string? platformKey = null) + { + if (verifySha) + { + var expected = _pins.Sha256For(platformKey!); + var actual = Sha256Hex(bundleBytes); + if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"StackQL bundle sha256 mismatch for {platformKey}: expected {expected}, got {actual}. " + + "Refusing to run an unverified binary. If the pins are not yet populated, set " + + "STACKQL_MCP_BIN or STACKQL_MCP_BUNDLE for local development."); + } + } + + var binaryName = Platform.BinaryFileName(); + var finalBinary = Path.Combine(cacheDir, binaryName); + + // Another process may have won the race while we downloaded. + if (File.Exists(finalBinary)) + { + return finalBinary; + } + + var parent = Directory.GetParent(cacheDir)?.FullName + ?? throw new InvalidOperationException($"Cache dir '{cacheDir}' has no parent."); + Directory.CreateDirectory(parent); + + var tempDir = Path.Combine(parent, $".{Path.GetFileName(cacheDir)}.tmp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + using (var ms = new MemoryStream(bundleBytes, writable: false)) + using (var zip = new ZipArchive(ms, ZipArchiveMode.Read)) + { + ExtractAll(zip, tempDir); + } + + var extractedBinary = Path.Combine(tempDir, binaryName); + if (!File.Exists(extractedBinary)) + { + // Some bundles nest the binary under a bin/ dir; search for it. + extractedBinary = Directory + .EnumerateFiles(tempDir, binaryName, SearchOption.AllDirectories) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Bundle did not contain expected binary '{binaryName}'."); + } + + MakeExecutable(extractedBinary); + + // Move temp into place. If we lost the race, just use the winner. + if (!File.Exists(finalBinary)) + { + try + { + Directory.Move(tempDir, cacheDir); + tempDir = string.Empty; // moved; do not clean up + } + catch (IOException) when (Directory.Exists(cacheDir)) + { + // Lost the race; the winner's cache is authoritative. + } + } + + // Re-resolve from the final cache location (handles nested layouts). + if (File.Exists(finalBinary)) + { + return finalBinary; + } + + return Directory + .EnumerateFiles(cacheDir, binaryName, SearchOption.AllDirectories) + .First(); + } + finally + { + if (!string.IsNullOrEmpty(tempDir) && Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ } + } + } + } + + private static void ExtractAll(ZipArchive zip, string destDir) + { + var destFull = Path.GetFullPath(destDir); + foreach (var entry in zip.Entries) + { + // Skip directory entries. + if (string.IsNullOrEmpty(entry.Name)) + { + continue; + } + + var target = Path.GetFullPath(Path.Combine(destDir, entry.FullName)); + + // Zip-slip guard: refuse entries that escape the destination. + if (!target.StartsWith(destFull + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && !string.Equals(target, destFull, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Bundle entry '{entry.FullName}' escapes the extraction directory."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + entry.ExtractToFile(target, overwrite: true); + } + } + + private static void MakeExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + +#if NET8_0_OR_GREATER + var mode = File.GetUnixFileMode(path); + File.SetUnixFileMode(path, + mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); +#endif + } + + private byte[]? TryReadVendoredBundle() + { + var asm = typeof(BinaryResolver).Assembly; + using var stream = asm.GetManifestResourceStream("StackQL.Mcp.vendored-bundle.mcpb"); + if (stream is null) + { + return null; + } + + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + private static string RequireExisting(string path, string source) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"StackQL binary from {source} not found: {path}", path); + } + + return path; + } + + private static string Sha256Hex(byte[] bytes) + { + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/StackQL.Mcp/LaunchArgs.cs b/src/StackQL.Mcp/LaunchArgs.cs new file mode 100644 index 0000000..0b0f35a --- /dev/null +++ b/src/StackQL.Mcp/LaunchArgs.cs @@ -0,0 +1,57 @@ +using System.Text; +using System.Text.Json; + +namespace StackQL.Mcp; + +/// +/// Builds the canonical, cwd-independent argv for launching the StackQL MCP +/// server over stdio. cwd-independence is mandatory: MCP hosts may launch with +/// cwd / (read-only on macOS), so the approot must always be absolute and +/// explicit. This was a real Claude Desktop bug fixed in June 2026. +/// +internal static class LaunchArgs +{ + /// + /// Builds the argument list (excluding the executable itself) for: + /// + /// mcp --mcp.server.type=stdio --approot <approot> + /// --mcp.config {"server": {"mode": "<mode>", "audit": {"disabled": true}}} + /// + /// + /// Server safety mode (default read_only). + /// + /// Absolute approot directory. Defaults to ~/.stackql when null/empty. + /// + public static IReadOnlyList Build(StackqlMode mode, string? approot = null) + { + var resolvedApproot = string.IsNullOrWhiteSpace(approot) + ? Platform.StackqlHome() + : Path.GetFullPath(approot); + + return new[] + { + "mcp", + "--mcp.server.type=stdio", + "--approot", + resolvedApproot, + "--mcp.config", + BuildConfigJson(mode), + }; + } + + /// + /// The --mcp.config JSON payload. Audit is disabled because the + /// embedded host owns audit/observability; the SQL inspectability is the + /// audit trail for the embedded story. + /// + public static string BuildConfigJson(StackqlMode mode) + { + // Hand-build deterministic, compact JSON so the exact byte string is + // assertable in unit tests and stable across runtimes. + var sb = new StringBuilder(); + sb.Append("{\"server\": {\"mode\": "); + sb.Append(JsonSerializer.Serialize(mode.ToWireValue())); + sb.Append(", \"audit\": {\"disabled\": true}}}"); + return sb.ToString(); + } +} diff --git a/src/StackQL.Mcp/Pins.cs b/src/StackQL.Mcp/Pins.cs new file mode 100644 index 0000000..dd751c2 --- /dev/null +++ b/src/StackQL.Mcp/Pins.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using System.Text.Json; + +namespace StackQL.Mcp; + +/// +/// The pinned StackQL version and per-platform sha256 bundle checksums baked +/// into the package. The values live in the embedded pins.json resource so +/// a version bump is a one-file change. Format mirrors the packaging repo's +/// planned consolidated platforms.json release asset. +/// +internal sealed record Pins(string Version, IReadOnlyDictionary Sha256ByPlatform) +{ + private const string ResourceName = "StackQL.Mcp.pins.json"; + + /// The sha256 (hex, lowercase) of the bundle for a platform key. + public string Sha256For(string platformKey) + { + if (!Sha256ByPlatform.TryGetValue(platformKey, out var sha) || string.IsNullOrWhiteSpace(sha)) + { + throw new PlatformNotSupportedException( + $"No pinned StackQL bundle sha256 for platform '{platformKey}' in v{Version}."); + } + + return sha; + } + + /// The bundle asset name for a platform, e.g. stackql-mcp-windows-x64.mcpb. + public static string BundleAssetName(string platformKey) => $"stackql-mcp-{platformKey}.mcpb"; + + /// Loads and caches the embedded pins resource. + public static Pins Load() + { + var asm = typeof(Pins).Assembly; + using var stream = asm.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"Embedded resource '{ResourceName}' not found. Available: " + + string.Join(", ", asm.GetManifestResourceNames())); + + var doc = JsonSerializer.Deserialize(stream, JsonOpts) + ?? throw new InvalidOperationException("pins.json deserialized to null."); + + if (string.IsNullOrWhiteSpace(doc.Version) || doc.Platforms is null) + { + throw new InvalidOperationException("pins.json is missing 'version' or 'platforms'."); + } + + return new Pins(doc.Version, doc.Platforms); + } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + private sealed record PinsDto(string Version, Dictionary Platforms); +} diff --git a/src/StackQL.Mcp/Platform.cs b/src/StackQL.Mcp/Platform.cs new file mode 100644 index 0000000..c925716 --- /dev/null +++ b/src/StackQL.Mcp/Platform.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; + +namespace StackQL.Mcp; + +/// +/// Resolves the StackQL platform key and shared cache locations. The platform +/// keys and the ~/.stackql/mcp-server-bin/<version>/<platform-key>/ +/// cache path are shared verbatim with the npm/pypi/go/rust/kotlin/swift wrappers, +/// so cross-runtime cache reuse works. +/// +internal static class Platform +{ + /// Platform keys defined by the packaging repo. + public const string LinuxX64 = "linux-x64"; + public const string LinuxArm64 = "linux-arm64"; + public const string WindowsX64 = "windows-x64"; + public const string DarwinUniversal = "darwin-universal"; + + /// + /// The platform key for the current runtime, e.g. windows-x64. + /// macOS collapses to darwin-universal regardless of arch. + /// + /// + /// The current OS/arch combination has no published StackQL bundle. + /// + public static string CurrentKey() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return DarwinUniversal; + } + + var arch = RuntimeInformation.OSArchitecture; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return arch switch + { + Architecture.X64 => WindowsX64, + _ => throw Unsupported("Windows", arch), + }; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return arch switch + { + Architecture.X64 => LinuxX64, + Architecture.Arm64 => LinuxArm64, + _ => throw Unsupported("Linux", arch), + }; + } + + throw new PlatformNotSupportedException( + $"No StackQL MCP bundle for OS '{RuntimeInformation.OSDescription}'."); + } + + /// The executable name inside a bundle for the current OS. + public static string BinaryFileName() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "stackql.exe" : "stackql"; + + /// + /// The StackQL home directory, ~/.stackql. Honors no override directly; + /// callers that need a custom approot pass it through the builder. + /// + public static string StackqlHome() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + { + // Fall back to HOME on platforms where UserProfile is empty. + home = Environment.GetEnvironmentVariable("HOME") + ?? throw new InvalidOperationException("Cannot resolve the user home directory."); + } + + return Path.Combine(home, ".stackql"); + } + + /// + /// Shared binary cache directory for a given version + platform key: + /// ~/.stackql/mcp-server-bin/<version>/<platform-key>/. + /// + public static string BinCacheDir(string version, string platformKey) => + Path.Combine(StackqlHome(), "mcp-server-bin", version, platformKey); + + private static PlatformNotSupportedException Unsupported(string os, Architecture arch) => + new($"No StackQL MCP bundle for {os} on {arch}."); +} diff --git a/src/StackQL.Mcp/StackQL.Mcp.csproj b/src/StackQL.Mcp/StackQL.Mcp.csproj new file mode 100644 index 0000000..7eb4351 --- /dev/null +++ b/src/StackQL.Mcp/StackQL.Mcp.csproj @@ -0,0 +1,38 @@ + + + + net8.0;net9.0 + true + true + StackQL.Mcp + StackQL embedded MCP server for .NET + Embeds the StackQL MCP server in a .NET app. Query and provision cloud and SaaS resources with SQL across AWS, Azure, Google, GitHub, Databricks and 40+ providers, over stdio, with a read-only-by-default safety model. Sidecar (download-and-verify) or vendored (single-file) acquisition. + stackql;mcp;model-context-protocol;cloud;aws;azure;gcp;agent;sql;iac + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/StackQL.Mcp/StackqlMcp.cs b/src/StackQL.Mcp/StackqlMcp.cs new file mode 100644 index 0000000..28aa7cc --- /dev/null +++ b/src/StackQL.Mcp/StackqlMcp.cs @@ -0,0 +1,22 @@ +namespace StackQL.Mcp; + +/// +/// Entry point for embedding the StackQL MCP server in a .NET application. +/// +/// +/// +/// await using var server = await StackqlMcp.CreateBuilder() +/// .WithMode(StackqlMode.ReadOnly) +/// .WithAuth("github", "null_auth") +/// .StartAsync(); +/// +/// var result = await server.CallToolAsync("list_services", +/// new() { ["provider"] = "github", ["row_limit"] = 5 }); +/// Console.WriteLine(result.Text); +/// +/// +public static class StackqlMcp +{ + /// Creates a new builder with read-only mode as the default. + public static StackqlMcpBuilder CreateBuilder() => new(); +} diff --git a/src/StackQL.Mcp/StackqlMcpBuilder.cs b/src/StackQL.Mcp/StackqlMcpBuilder.cs new file mode 100644 index 0000000..3b3d744 --- /dev/null +++ b/src/StackQL.Mcp/StackqlMcpBuilder.cs @@ -0,0 +1,182 @@ +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Client; + +namespace StackQL.Mcp; + +/// +/// Fluent builder for an embedded StackQL MCP server. Defaults to +/// ; every escalation and override is explicit. +/// +public sealed class StackqlMcpBuilder +{ + private StackqlMode _mode = StackqlMode.ReadOnly; + private string? _approot; + private string? _binaryPath; + private string? _bundlePath; + private IReadOnlyList? _command; + private HttpClient? _httpClient; + private readonly Dictionary _auth = new(StringComparer.Ordinal); + private readonly Dictionary _env = new(StringComparer.Ordinal); + + internal StackqlMcpBuilder() + { + } + + /// Sets the server safety mode. Default is . + public StackqlMcpBuilder WithMode(StackqlMode mode) + { + _mode = mode; + return this; + } + + /// + /// Configures auth for a provider. is a StackQL + /// auth type such as null_auth, aws_signing, service_account, + /// or access_token. Credential material is read by StackQL from the + /// environment; this only declares which auth type a provider uses. + /// + public StackqlMcpBuilder WithAuth(string provider, string authType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(provider); + ArgumentException.ThrowIfNullOrWhiteSpace(authType); + _auth[provider] = authType; + return this; + } + + /// Overrides the approot directory. Defaults to ~/.stackql. + public StackqlMcpBuilder WithApproot(string approot) + { + _approot = approot; + return this; + } + + /// Uses an explicit StackQL binary, bypassing acquisition. + public StackqlMcpBuilder WithBinary(string binaryPath) + { + _binaryPath = binaryPath; + return this; + } + + /// Uses an explicit .mcpb bundle, bypassing download. + public StackqlMcpBuilder WithBundlePath(string bundlePath) + { + _bundlePath = bundlePath; + return this; + } + + /// + /// Overrides the entire launch command (argv). Element 0 is the executable. + /// For advanced hosts that fully own argv construction; bypasses + /// / arg generation. + /// + public StackqlMcpBuilder WithCommand(IReadOnlyList command) + { + if (command is null || command.Count == 0) + { + throw new ArgumentException("Command must contain at least the executable.", nameof(command)); + } + + _command = command; + return this; + } + + /// Sets an extra environment variable for the server process. + public StackqlMcpBuilder WithEnvironment(string name, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + _env[name] = value; + return this; + } + + /// Supplies an for sidecar downloads. + public StackqlMcpBuilder WithHttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + return this; + } + + /// Resolves the binary if needed, spawns the server, and connects. + public async Task StartAsync(CancellationToken ct = default) + { + var (command, arguments) = await ResolveArgvAsync(ct).ConfigureAwait(false); + + var options = new StdioClientTransportOptions + { + Name = "stackql", + Command = command, + Arguments = arguments.ToArray(), + EnvironmentVariables = BuildEnvironment(), + }; + + var transport = new StdioClientTransport(options); + var client = await McpClient.CreateAsync(transport, cancellationToken: ct).ConfigureAwait(false); + return new StackqlServer(client); + } + + private async Task<(string Command, IReadOnlyList Arguments)> ResolveArgvAsync(CancellationToken ct) + { + if (_command is not null) + { + return (_command[0], _command.Skip(1).ToList()); + } + + var ownsHttp = _httpClient is null; + var http = _httpClient ?? new HttpClient(); + try + { + var resolver = new BinaryResolver(Pins.Load(), http); + var exe = await resolver.ResolveAsync(_binaryPath, _bundlePath, ct).ConfigureAwait(false); + return (exe, LaunchArgs.Build(_mode, _approot)); + } + finally + { + if (ownsHttp) + { + http.Dispose(); + } + } + } + + private Dictionary BuildEnvironment() + { + var env = new Dictionary(StringComparer.Ordinal); + + // Declare provider auth types via StackQL's AUTH env var (JSON map). + if (_auth.Count > 0) + { + env["AUTH"] = BuildAuthJson(); + } + + foreach (var kv in _env) + { + env[kv.Key] = kv.Value; + } + + return env; + } + + private string BuildAuthJson() + { + // StackQL AUTH format: { "": { "type": "" }, ... } + var sb = new StringBuilder(); + sb.Append('{'); + var first = true; + foreach (var kv in _auth) + { + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append(JsonSerializer.Serialize(kv.Key)); + sb.Append(":{\"type\":"); + sb.Append(JsonSerializer.Serialize(kv.Value)); + sb.Append('}'); + } + + sb.Append('}'); + return sb.ToString(); + } +} diff --git a/src/StackQL.Mcp/StackqlMode.cs b/src/StackQL.Mcp/StackqlMode.cs new file mode 100644 index 0000000..7846987 --- /dev/null +++ b/src/StackQL.Mcp/StackqlMode.cs @@ -0,0 +1,37 @@ +namespace StackQL.Mcp; + +/// +/// Server safety mode. Maps to the StackQL MCP server's server.mode config. +/// The default is ; escalation is always an explicit caller +/// opt-in, never a default. +/// +public enum StackqlMode +{ + /// Queries only. No mutations of any kind. The default. + ReadOnly, + + /// Creates and updates, but no deletes. + Safe, + + /// Creates, updates, and deletes the server classes as safe. + DeleteSafe, + + /// All operations, including unrestricted deletes. + FullAccess, +} + +internal static class StackqlModeExtensions +{ + /// + /// The wire token the StackQL MCP server expects in its --mcp.config + /// server.mode field. + /// + public static string ToWireValue(this StackqlMode mode) => mode switch + { + StackqlMode.ReadOnly => "read_only", + StackqlMode.Safe => "safe", + StackqlMode.DeleteSafe => "delete_safe", + StackqlMode.FullAccess => "full_access", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown StackQL mode."), + }; +} diff --git a/src/StackQL.Mcp/StackqlServer.cs b/src/StackQL.Mcp/StackqlServer.cs new file mode 100644 index 0000000..f0d2134 --- /dev/null +++ b/src/StackQL.Mcp/StackqlServer.cs @@ -0,0 +1,88 @@ +using ModelContextProtocol.Client; + +namespace StackQL.Mcp; + +/// +/// A running, embedded StackQL MCP server with a connected MCP client. Obtain one +/// via and . +/// Dispose to stop the server process and release the transport. +/// +public sealed class StackqlServer : IAsyncDisposable +{ + private readonly McpClient _client; + private IReadOnlyList? _toolsCache; + + internal StackqlServer(McpClient client) + { + _client = client; + } + + /// + /// The connected MCP client (official C# SDK) for advanced use beyond the + /// convenience methods on this type. + /// + public McpClient Client => _client; + + /// Lists the tools the StackQL server exposes (e.g. list_services, + /// pull_provider, list_providers). Cached after the first call. + public async Task> ListToolsAsync(CancellationToken ct = default) + { + _toolsCache ??= (IReadOnlyList)await _client.ListToolsAsync(cancellationToken: ct) + .ConfigureAwait(false); + return _toolsCache; + } + + /// Calls a StackQL tool by name with the given arguments. + /// Tool name, e.g. list_services. + /// Tool arguments, or null for none. + public async Task CallToolAsync( + string name, + IReadOnlyDictionary? arguments = null, + CancellationToken ct = default) + { + var raw = await _client.CallToolAsync(name, arguments, cancellationToken: ct) + .ConfigureAwait(false); + return new ToolResult(raw); + } + + /// + /// Returns the argv an external harness (or Agent Framework's own MCP client) + /// should spawn to run the StackQL server itself, so callers that prefer to own + /// the process still get the canonical, cwd-independent arguments from us. + /// Element 0 is the executable; the rest are arguments. The binary is resolved + /// (and acquired if needed) the same way + /// resolves it. + /// + public static async Task> ResolveCommandAsync( + StackqlMode mode = StackqlMode.ReadOnly, + string? approot = null, + string? binaryPath = null, + string? bundlePath = null, + HttpClient? httpClient = null, + CancellationToken ct = default) + { + var ownsHttp = httpClient is null; + var http = httpClient ?? new HttpClient(); + try + { + var resolver = new BinaryResolver(Pins.Load(), http); + var exe = await resolver.ResolveAsync(binaryPath, bundlePath, ct).ConfigureAwait(false); + var argv = new List { exe }; + argv.AddRange(LaunchArgs.Build(mode, approot)); + return argv; + } + finally + { + if (ownsHttp) + { + http.Dispose(); + } + } + } + + /// + public async ValueTask DisposeAsync() + { + await _client.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/StackQL.Mcp/ToolResult.cs b/src/StackQL.Mcp/ToolResult.cs new file mode 100644 index 0000000..43e125e --- /dev/null +++ b/src/StackQL.Mcp/ToolResult.cs @@ -0,0 +1,34 @@ +using ModelContextProtocol.Protocol; + +namespace StackQL.Mcp; + +/// +/// A flattened view over an MCP : the concatenated +/// text content plus the raw result for callers that need structured content or +/// the error flag. +/// +public sealed class ToolResult +{ + internal ToolResult(CallToolResult raw) + { + Raw = raw; + Text = string.Join( + "\n", + raw.Content + .OfType() + .Select(b => b.Text)); + IsError = raw.IsError ?? false; + } + + /// Concatenated text blocks from the tool result. + public string Text { get; } + + /// True if the server flagged the call as an error. + public bool IsError { get; } + + /// The underlying MCP result for structured/advanced access. + public CallToolResult Raw { get; } + + /// + public override string ToString() => Text; +} diff --git a/src/StackQL.Mcp/pins.json b/src/StackQL.Mcp/pins.json new file mode 100644 index 0000000..4e34471 --- /dev/null +++ b/src/StackQL.Mcp/pins.json @@ -0,0 +1,10 @@ +{ + "_comment": "Pinned StackQL MCP server version and per-platform bundle sha256 checksums. Bump 'version' and replace the sha256 values from the release's .sha256 assets (or the consolidated platforms.json asset) at publish time. The 'PLACEHOLDER' values are intentionally invalid so an unconfigured sidecar download fails the integrity check loudly rather than running an unverified binary; set STACKQL_MCP_BIN or STACKQL_MCP_BUNDLE to bypass for local development.", + "version": "0.10.500", + "platforms": { + "linux-x64": "PLACEHOLDER_REPLACE_WITH_RELEASE_SHA256", + "linux-arm64": "PLACEHOLDER_REPLACE_WITH_RELEASE_SHA256", + "windows-x64": "PLACEHOLDER_REPLACE_WITH_RELEASE_SHA256", + "darwin-universal": "PLACEHOLDER_REPLACE_WITH_RELEASE_SHA256" + } +} diff --git a/tests/StackQL.Mcp.Tests/ConformanceTests.cs b/tests/StackQL.Mcp.Tests/ConformanceTests.cs new file mode 100644 index 0000000..4d972bc --- /dev/null +++ b/tests/StackQL.Mcp.Tests/ConformanceTests.cs @@ -0,0 +1,65 @@ +using StackQL.Mcp; +using StackQL.Mcp.AgentFramework; +using Xunit; + +namespace StackQL.Mcp.Tests; + +/// +/// The shared embedded-MCP-family conformance check, ported from the packaging +/// repo's smoke test: initialize -> tools/list contains the core tools -> pull the +/// github provider (null_auth) -> list_services returns real services. Skips when +/// no StackQL binary is available so the unit suite stays runnable everywhere. +/// +[Collection("integration")] +public class ConformanceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3); + + [SkippableFact] + public async Task Conformance_InitializeListPullList() + { + Skip.IfNot(TestServer.BinaryAvailable, TestServer.SkipReason); + + using var cts = new CancellationTokenSource(Timeout); + var ct = cts.Token; + + await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) + .WithAuth("github", "null_auth") + .StartAsync(ct); + + // tools/list contains the core tools. + var tools = await server.ListToolsAsync(ct); + var toolNames = tools.Select(t => t.Name).ToHashSet(); + Assert.Contains("list_providers", toolNames); + Assert.Contains("list_services", toolNames); + Assert.Contains("pull_provider", toolNames); + + // pull github (null_auth). + var pull = await server.CallToolAsync("pull_provider", + new Dictionary { ["provider"] = "github" }, ct); + Assert.False(pull.IsError, $"pull_provider failed: {pull.Text}"); + + // list_services returns real services for github. + var services = await server.CallToolAsync("list_services", + new Dictionary { ["provider"] = "github", ["row_limit"] = 5 }, ct); + Assert.False(services.IsError, $"list_services failed: {services.Text}"); + Assert.False(string.IsNullOrWhiteSpace(services.Text)); + } + + [SkippableFact] + public async Task AsAgentTools_ReturnsStackqlToolsAsAITools() + { + Skip.IfNot(TestServer.BinaryAvailable, TestServer.SkipReason); + + using var cts = new CancellationTokenSource(Timeout); + await using var server = await StackqlMcp.CreateBuilder() + .WithMode(StackqlMode.ReadOnly) + .StartAsync(cts.Token); + + var aiTools = await server.AsAgentToolsAsync(cts.Token); + + Assert.NotEmpty(aiTools); + Assert.Contains(aiTools, t => t.Name == "list_services"); + } +} diff --git a/tests/StackQL.Mcp.Tests/LaunchArgsTests.cs b/tests/StackQL.Mcp.Tests/LaunchArgsTests.cs new file mode 100644 index 0000000..c815ea1 --- /dev/null +++ b/tests/StackQL.Mcp.Tests/LaunchArgsTests.cs @@ -0,0 +1,61 @@ +using StackQL.Mcp; +using Xunit; + +namespace StackQL.Mcp.Tests; + +public class LaunchArgsTests +{ + [Fact] + public void Build_UsesCanonicalArgvShape() + { + var args = LaunchArgs.Build(StackqlMode.ReadOnly, approot: "/tmp/approot"); + + Assert.Equal("mcp", args[0]); + Assert.Equal("--mcp.server.type=stdio", args[1]); + Assert.Equal("--approot", args[2]); + Assert.Equal("--mcp.config", args[4]); + Assert.Equal(6, args.Count); + } + + [Fact] + public void Build_ApprootIsAbsolute_EvenFromRelativeInput() + { + // cwd-independence is mandatory: relative input must be made absolute. + var args = LaunchArgs.Build(StackqlMode.ReadOnly, approot: "relative/dir"); + var approot = args[3]; + + Assert.True(Path.IsPathRooted(approot), $"approot '{approot}' must be absolute"); + } + + [Fact] + public void Build_NullApproot_DefaultsToStackqlHome() + { + var args = LaunchArgs.Build(StackqlMode.ReadOnly, approot: null); + var approot = args[3]; + + Assert.EndsWith(".stackql", approot); + Assert.True(Path.IsPathRooted(approot)); + } + + [Theory] + [InlineData(StackqlMode.ReadOnly, "read_only")] + [InlineData(StackqlMode.Safe, "safe")] + [InlineData(StackqlMode.DeleteSafe, "delete_safe")] + [InlineData(StackqlMode.FullAccess, "full_access")] + public void ConfigJson_EncodesModeAndDisablesAudit(StackqlMode mode, string wire) + { + var json = LaunchArgs.BuildConfigJson(mode); + + Assert.Equal( + $"{{\"server\": {{\"mode\": \"{wire}\", \"audit\": {{\"disabled\": true}}}}}}", + json); + } + + [Fact] + public void ConfigJson_DefaultModeIsReadOnly() + { + // The default for the public API is ReadOnly; assert the wire token here. + var json = LaunchArgs.BuildConfigJson(StackqlMode.ReadOnly); + Assert.Contains("\"mode\": \"read_only\"", json); + } +} diff --git a/tests/StackQL.Mcp.Tests/PinsAndPlatformTests.cs b/tests/StackQL.Mcp.Tests/PinsAndPlatformTests.cs new file mode 100644 index 0000000..c7af7ee --- /dev/null +++ b/tests/StackQL.Mcp.Tests/PinsAndPlatformTests.cs @@ -0,0 +1,70 @@ +using StackQL.Mcp; +using Xunit; + +namespace StackQL.Mcp.Tests; + +public class PinsAndPlatformTests +{ + [Fact] + public void Pins_Load_HasVersionAndAllFourPlatforms() + { + var pins = Pins.Load(); + + Assert.False(string.IsNullOrWhiteSpace(pins.Version)); + Assert.Equal(4, pins.Sha256ByPlatform.Count); + Assert.Contains(Platform.LinuxX64, pins.Sha256ByPlatform.Keys); + Assert.Contains(Platform.LinuxArm64, pins.Sha256ByPlatform.Keys); + Assert.Contains(Platform.WindowsX64, pins.Sha256ByPlatform.Keys); + Assert.Contains(Platform.DarwinUniversal, pins.Sha256ByPlatform.Keys); + } + + [Fact] + public void Pins_Sha256For_UnknownPlatform_Throws() + { + var pins = Pins.Load(); + Assert.Throws(() => pins.Sha256For("solaris-sparc")); + } + + [Theory] + [InlineData("linux-x64", "stackql-mcp-linux-x64.mcpb")] + [InlineData("windows-x64", "stackql-mcp-windows-x64.mcpb")] + [InlineData("darwin-universal", "stackql-mcp-darwin-universal.mcpb")] + public void Pins_BundleAssetName_FollowsConvention(string platform, string expected) + { + Assert.Equal(expected, Pins.BundleAssetName(platform)); + } + + [Fact] + public void Platform_CurrentKey_IsOneOfTheFour() + { + var key = Platform.CurrentKey(); + Assert.Contains(key, new[] + { + Platform.LinuxX64, Platform.LinuxArm64, Platform.WindowsX64, Platform.DarwinUniversal, + }); + } + + [Fact] + public void Platform_BinCacheDir_MatchesSharedFamilyLayout() + { + var dir = Platform.BinCacheDir("0.10.500", "windows-x64"); + + // ~/.stackql/mcp-server-bin/// + var normalized = dir.Replace('\\', '/'); + Assert.Contains(".stackql/mcp-server-bin/0.10.500/windows-x64", normalized); + } + + [Fact] + public void Platform_BinaryFileName_HasExeOnWindowsOnly() + { + var name = Platform.BinaryFileName(); + if (OperatingSystem.IsWindows()) + { + Assert.Equal("stackql.exe", name); + } + else + { + Assert.Equal("stackql", name); + } + } +} diff --git a/tests/StackQL.Mcp.Tests/StackQL.Mcp.Tests.csproj b/tests/StackQL.Mcp.Tests/StackQL.Mcp.Tests.csproj new file mode 100644 index 0000000..1290b3b --- /dev/null +++ b/tests/StackQL.Mcp.Tests/StackQL.Mcp.Tests.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + false + true + + + + + + + + + + + + + + + + diff --git a/tests/StackQL.Mcp.Tests/TestServer.cs b/tests/StackQL.Mcp.Tests/TestServer.cs new file mode 100644 index 0000000..34d430c --- /dev/null +++ b/tests/StackQL.Mcp.Tests/TestServer.cs @@ -0,0 +1,24 @@ +namespace StackQL.Mcp.Tests; + +/// +/// Helpers for the integration tests that need a real StackQL binary. The binary +/// is only available when a developer/CI set STACKQL_MCP_BIN or STACKQL_MCP_BUNDLE, +/// or once pins.json carries real release shas. Until then these tests skip rather +/// than fail, so the unit suite stays green everywhere. +/// +internal static class TestServer +{ + /// + /// True when a StackQL binary can be resolved without a verified download, + /// i.e. an explicit binary or bundle override is present. + /// + public static bool BinaryAvailable => + HasFile(Environment.GetEnvironmentVariable("STACKQL_MCP_BIN")) || + HasFile(Environment.GetEnvironmentVariable("STACKQL_MCP_BUNDLE")); + + public const string SkipReason = + "No StackQL binary available. Set STACKQL_MCP_BIN or STACKQL_MCP_BUNDLE to run integration tests."; + + private static bool HasFile(string? path) => + !string.IsNullOrWhiteSpace(path) && File.Exists(path); +}