diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 316c1e0..dcf422b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,14 @@ jobs: exit 1 fi chmod +x publish/dcp - # No API key here, so the server must fail on the key and nothing else. - output=$(./publish/dcp &1 || true) + # No credential here, so the server must stop at its credential check, with one plain line and exit 1. + set +e + output=$(./publish/dcp &1) + code=$? + set -e echo "$output" | head -3 - echo "$output" | grep -q "API Key was not provided" || { - echo "binary did not reach its startup key check"; exit 1; } + echo "$output" | grep -q "dcp: No credential was provided" || { + echo "binary did not reach its startup credential check"; exit 1; } + [ "$code" -eq 1 ] || { echo "expected exit code 1, got $code"; exit 1; } + if echo "$output" | grep -q "Unhandled exception"; then + echo "startup failure is a stack trace, not one plain line"; exit 1; fi diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 056978c..7bb3ffe 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -2,8 +2,8 @@ "inputs": [ { "type": "promptString", - "id": "if-key", - "description": "DeepCube API Key", + "id": "if-token", + "description": "DeepCube personal access token", "password": true } ], @@ -17,7 +17,7 @@ "${workspaceFolder}/src/IF.APM.App.MCP.Server/IF.APM.App.MCP.Server.csproj" ], "env": { - "IF_ApiKey": "${input:if-key}" + "IF_Token": "${input:if-token}" } } } diff --git a/README.md b/README.md index 19bf87e..c9ed291 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,25 @@ The server ships as a single self-contained executable named **`dcp`**. It has n dependencies: you do not need the .NET runtime, or anything else, on the machine that runs it. -### 1. Get an API key +### 1. Create a personal access token -You need a DeepCube Grid API key. Your grids and keys live in the -[DeepCube portal](https://portal.deepcube.ai). If you do not have a subscription yet, -start with the [Getting Started](https://docs.deepcube.ai/Getting-Started) guide. +The server reads one grid with a **personal access token** bound to that grid. A token can +last up to 90 days; create a new one when it expires. If you do not have a subscription +yet, start with the [Getting Started](https://docs.deepcube.ai/Getting-Started) guide. -The key identifies which grid the server reads, so anyone holding it can read that -grid's telemetry. Treat it as a secret: keep it out of source control and out of -anything you share. +Creating these tokens yourself in the portal is coming soon. Until then: + +- If your grid API key was created before October 2026 and has not been rotated, it still + reads: set it in `IF_ApiKey` instead of `IF_Token`. +- Otherwise, contact DeepCube support for a personal access token for your grid. + +**Use a personal access token, not a grid API key.** A grid API key is for sending +telemetry, and keys created or rotated from October 2026 can do nothing else, so the +server cannot read with them. It says so at startup. A key created before then still +works until you rotate it. + +The token reads that grid's telemetry as you, so treat it as a secret: keep it out of +source control and out of anything you share. ### 2. Download the binary @@ -65,19 +75,19 @@ xattr -d com.apple.quarantine ~/.local/bin/dcp ### 3. Point your MCP client at it -The server speaks MCP over stdio and reads its key from the `IF_ApiKey` environment +The server speaks MCP over stdio and reads its token from the `IF_Token` environment variable. Use the full path to the binary you unzipped. -**VS Code with GitHub Copilot** — `.vscode/mcp.json` in your workspace. This form prompts -for the key and stores it in VS Code's secret storage rather than in the file: +**VS Code with GitHub Copilot**: `.vscode/mcp.json` in your workspace. This form prompts +for the token and stores it in VS Code's secret storage rather than in the file: ```json { "inputs": [ { "type": "promptString", - "id": "if-key", - "description": "DeepCube API Key", + "id": "if-token", + "description": "DeepCube personal access token", "password": true } ], @@ -86,7 +96,7 @@ for the key and stores it in VS Code's secret storage rather than in the file: "type": "stdio", "command": "C:\\Users\\you\\AppData\\Local\\Programs\\dcp\\dcp.exe", "env": { - "IF_ApiKey": "${input:if-key}" + "IF_Token": "${input:if-token}" } } } @@ -96,7 +106,7 @@ for the key and stores it in VS Code's secret storage rather than in the file: **Claude Code**: ```bash -claude mcp add --scope local deepcube -e IF_ApiKey= -- ~/.local/bin/dcp +claude mcp add --scope local deepcube -e IF_Token= -- ~/.local/bin/dcp ``` **Claude Desktop, Cursor, and other clients** take the same shape in their own config @@ -107,13 +117,13 @@ file: "mcpServers": { "deepcube": { "command": "/absolute/path/to/dcp", - "env": { "IF_ApiKey": "" } + "env": { "IF_Token": "" } } } } ``` -By default the server talks to `https://api-azure.deepcube.ai`. Set `IF_BaseUrl` in the +By default the server talks to `https://web.deepcube.ai`. Set `IF_BaseUrl` in the same `env` block to point it somewhere else. ### 4. Verify @@ -124,12 +134,13 @@ Restart your client and confirm the server connects. In Claude Code: claude mcp list ``` -You should see `deepcube: ... - ✓ Connected`, which means the key was accepted and the +You should see `deepcube: ... - ✓ Connected`, which means the token was accepted and the server read your grid. Then ask your agent something like *"is everything ok?"* and it should call `GetSystemHealth`. -If it fails to start, run `dcp` directly in a terminal with `IF_ApiKey` set: it writes -startup errors, including an invalid or expired key, to standard error. +If it fails to start, run `dcp` directly in a terminal with `IF_Token` set: it writes +startup errors, including an expired token or a grid API key that can only send +telemetry, to standard error. ## 🧑‍💻 Building from source diff --git a/src/IF.APM.App.MCP.Server/Bootstrapper.cs b/src/IF.APM.App.MCP.Server/Bootstrapper.cs index b64c335..3fd38ac 100644 --- a/src/IF.APM.App.MCP.Server/Bootstrapper.cs +++ b/src/IF.APM.App.MCP.Server/Bootstrapper.cs @@ -15,6 +15,22 @@ public class BootstrapResult public class Bootstrapper { + /// The API the server reads from unless IF_BaseUrl names another. + public const string DefaultBaseUrl = "https://web.deepcube.ai"; + + /// Returns when it is set, else . + public static string ResolveBaseUrl(string? baseUrl) => + string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl.Trim().TrimEnd('/'); + + /// The scope a token needs to read a grid's telemetry. + public const string MemberScope = "member"; + + private const string ScopeClaim = "scp"; + private const string GridClaim = "gsid"; + + private const string HowToGetAPat = + "Create a personal access token for the grid in DeepCube and set it in the IF_Token environment variable."; + private readonly ISecurityClient _securityClient; public Bootstrapper(ISecurityClient securityClient) @@ -22,20 +38,54 @@ public Bootstrapper(ISecurityClient securityClient) _securityClient = securityClient; } - public async Task InitializeAsync(string? apiKey, string? baseUrl) + /// + /// Resolves the credential this server reads with: a personal access token (IF_Token), used as it is, or + /// a grid API key (IF_ApiKey), exchanged for its token. + /// + /// + /// A grid API key created or rotated after CR-1 may only send telemetry (scope otlp), so every read this + /// server makes would be refused. Such a key is refused here, at startup, with what to use instead, rather than + /// failing every tool call later. Keys created before CR-1 carry member and keep working. + /// + public async Task InitializeAsync(string? token, string? apiKey, string? baseUrl) { - if (string.IsNullOrWhiteSpace(apiKey)) + var hasToken = !string.IsNullOrWhiteSpace(token); + var hasApiKey = !string.IsNullOrWhiteSpace(apiKey); + + if (hasToken && hasApiKey) + { + throw new AuthenticationException("Set IF_Token or IF_ApiKey, not both."); + } + + if (!hasToken && !hasApiKey) { throw new AuthenticationException( - "API Key was not provided. Please set the IF_ApiKey environment variable."); + $"No credential was provided. Set IF_Token to a personal access token for the grid. {HowToGetAPat}"); } - var resolvedBaseUrl = baseUrl ?? "https://api-azure.deepcube.ai"; + var resolvedBaseUrl = ResolveBaseUrl(baseUrl); - var wrapViewModel = await _securityClient.UnwrapApiKeyAsync(new UnwrapApiKeyModel(apiKey)); - var accessToken = wrapViewModel.Token; + string accessToken; + string source; + if (hasToken) + { + accessToken = token!.Trim(); + source = "The token in IF_Token"; + if (!new JwtSecurityTokenHandler().CanReadToken(accessToken)) + { + throw new AuthenticationException( + $"The value of IF_Token is not a personal access token. {HowToGetAPat}"); + } + } + else + { + var wrapViewModel = await _securityClient.UnwrapApiKeyAsync(new UnwrapApiKeyModel(apiKey!)); + accessToken = wrapViewModel.Token; + source = "This grid API key"; + } var jwtToken = ValidateToken(accessToken); + EnsureCanRead(jwtToken, source); var gridSecondaryId = ExtractGridSecondaryId(jwtToken); return new BootstrapResult @@ -46,6 +96,27 @@ public async Task InitializeAsync(string? apiKey, string? baseU }; } + /// + /// Refuses a token that names its scopes and does not include : it cannot read. A token + /// that names no scopes is left to the API to decide. + /// + public static void EnsureCanRead(JwtSecurityToken jwtToken, string source) + { + var scopes = jwtToken.Claims.Where(c => c.Type == ScopeClaim).Select(c => c.Value).ToList(); + if (scopes.Count > 0 && !scopes.Contains(MemberScope, StringComparer.OrdinalIgnoreCase)) + { + throw new AuthenticationException( + $"{source} can only send telemetry, so it cannot read traces, logs or dashboards. Grid API keys are for " + + $"sending telemetry; this server needs a personal access token. {HowToGetAPat}"); + } + + if (!jwtToken.Claims.Any(c => c.Type == GridClaim)) + { + throw new AuthenticationException( + $"{source} is not bound to a grid. Create the personal access token for one grid. {HowToGetAPat}"); + } + } + public static JwtSecurityToken ValidateToken(string accessToken) { var handler = new JwtSecurityTokenHandler(); @@ -66,7 +137,7 @@ public static JwtSecurityToken ValidateToken(string accessToken) public static Guid ExtractGridSecondaryId(JwtSecurityToken jwtToken) { - var gridSecondaryIdClaim = jwtToken.Claims.SingleOrDefault(c => c.Type.Equals("gsid")); + var gridSecondaryIdClaim = jwtToken.Claims.SingleOrDefault(c => c.Type.Equals(GridClaim)); if (gridSecondaryIdClaim == null) { diff --git a/src/IF.APM.App.MCP.Server/Program.cs b/src/IF.APM.App.MCP.Server/Program.cs index 59844ad..bb5c200 100644 --- a/src/IF.APM.App.MCP.Server/Program.cs +++ b/src/IF.APM.App.MCP.Server/Program.cs @@ -1,3 +1,4 @@ +using System.Security.Authentication; using IF.Api.Http.Abstractions; using IF.APM.App.Http.Api.Client; using IF.APM.App.MCP.Server; @@ -18,16 +19,36 @@ consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); +// IF_Token: a personal access token for one grid. IF_ApiKey: a grid API key (see Bootstrapper). +// IF_BaseUrl: overrides the API host; unset, the server uses Bootstrapper.DefaultBaseUrl. +var token = builder.Configuration["Token"]; var apiKey = builder.Configuration["ApiKey"]; -var baseUrl = builder.Configuration["BaseUrl"]; +var baseUrl = Bootstrapper.ResolveBaseUrl(builder.Configuration["BaseUrl"]); var securityClient = new SecurityClient(new ApiClientConfiguration { - BaseUrl = baseUrl ?? "https://api-azure.deepcube.ai" + BaseUrl = baseUrl }, new HttpClient()); var bootstrapper = new Bootstrapper(securityClient); -var result = await bootstrapper.InitializeAsync(apiKey, baseUrl); +BootstrapResult result; +try +{ + result = await bootstrapper.InitializeAsync(token, apiKey, baseUrl); +} +catch (AuthenticationException ex) +{ + // A plain line on stderr, not a stack trace: the MCP client only says "connection closed". + await Console.Error.WriteLineAsync($"dcp: {ex.Message}"); + return 1; +} +catch (HttpRequestException ex) +{ + await Console.Error.WriteLineAsync( + $"dcp: could not reach the DeepCube API at {baseUrl} ({ex.Message}). " + + "Check the network, or set IF_BaseUrl to the API you use."); + return 1; +} var config = new ApiClientConfiguration { @@ -49,6 +70,7 @@ // Build and run the host. This starts the MCP server. await builder.Build().RunAsync(); +return 0; public class GridAnchor { diff --git a/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs b/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs index f2c92c4..8807324 100644 --- a/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs +++ b/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs @@ -21,16 +21,16 @@ public void SetUp() _securityClient = Substitute.For(); } - private static string CreateTestJwt(Guid gridSecondaryId, DateTime? expires = null, DateTime? notBefore = null) + private static string CreateTestJwt(Guid gridSecondaryId, DateTime? expires = null, DateTime? notBefore = null, + params string[] scopes) { var handler = new JwtSecurityTokenHandler(); var now = DateTime.UtcNow; + var claims = new List { new("gsid", gridSecondaryId.ToString()) }; + claims.AddRange((scopes.Length == 0 ? new[] { "member" } : scopes).Select(s => new Claim("scp", s))); var tokenDescriptor = new SecurityTokenDescriptor { - Subject = new ClaimsIdentity(new[] - { - new Claim("gsid", gridSecondaryId.ToString()) - }), + Subject = new ClaimsIdentity(claims), NotBefore = notBefore ?? now.AddMinutes(-5), Expires = expires ?? now.AddHours(1), SigningCredentials = new SigningCredentials(TestKey, SecurityAlgorithms.HmacSha256Signature) @@ -54,41 +54,54 @@ private static string CreateTestJwtWithClaims(IEnumerable claims, DateTim // ── InitializeAsync ── - [Test] - public void InitializeAsync_Throws_When_ApiKey_Is_Null() + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public async Task InitializeAsync_Throws_When_No_Credential_Is_Set(string? blank) { var bootstrapper = new Bootstrapper(_securityClient); - var act = async () => await bootstrapper.InitializeAsync(null, null); + var act = async () => await bootstrapper.InitializeAsync(blank, blank, null); - act.Should().ThrowAsync() - .WithMessage("*API Key*"); + await act.Should().ThrowAsync() + .WithMessage("*IF_Token*personal access token*"); } [Test] - public void InitializeAsync_Throws_When_ApiKey_Is_Empty() + public async Task InitializeAsync_Returns_Result_With_Valid_Token() { - var bootstrapper = new Bootstrapper(_securityClient); + var gridId = Guid.NewGuid(); + var token = CreateTestJwt(gridId); + _securityClient.UnwrapApiKeyAsync(Arg.Any()) + .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(token))); - var act = async () => await bootstrapper.InitializeAsync("", null); + var bootstrapper = new Bootstrapper(_securityClient); + var result = await bootstrapper.InitializeAsync(null, "valid-key", null); - act.Should().ThrowAsync() - .WithMessage("*API Key*"); + result.GridSecondaryId.Should().Be(gridId); + result.AccessToken.Should().Be(token); + result.BaseUrl.Should().Be("https://web.deepcube.ai"); } - [Test] - public void InitializeAsync_Throws_When_ApiKey_Is_Whitespace() + // api-azure.deepcube.ai was retired (founder 2026-11-04, SP-136); web.deepcube.ai is the single API host. + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void ResolveBaseUrl_Uses_The_Default_When_Unset(string? baseUrl) { - var bootstrapper = new Bootstrapper(_securityClient); - - var act = async () => await bootstrapper.InitializeAsync(" ", null); + Bootstrapper.ResolveBaseUrl(baseUrl).Should().Be(Bootstrapper.DefaultBaseUrl); + Bootstrapper.DefaultBaseUrl.Should().Be("https://web.deepcube.ai"); + } - act.Should().ThrowAsync() - .WithMessage("*API Key*"); + [TestCase("https://custom.api.com", "https://custom.api.com")] + [TestCase(" https://custom.api.com/ ", "https://custom.api.com")] + public void ResolveBaseUrl_Keeps_An_Override(string baseUrl, string expected) + { + Bootstrapper.ResolveBaseUrl(baseUrl).Should().Be(expected); } [Test] - public async Task InitializeAsync_Returns_Result_With_Valid_Token() + public async Task InitializeAsync_Uses_Custom_BaseUrl_When_Provided() { var gridId = Guid.NewGuid(); var token = CreateTestJwt(gridId); @@ -96,25 +109,100 @@ public async Task InitializeAsync_Returns_Result_With_Valid_Token() .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(token))); var bootstrapper = new Bootstrapper(_securityClient); - var result = await bootstrapper.InitializeAsync("valid-key", null); + var result = await bootstrapper.InitializeAsync(null, "valid-key", "https://custom.api.com"); + + result.BaseUrl.Should().Be("https://custom.api.com"); + } + + // ── a personal access token (IF_Token) ── + // + // CR-1 (App.Http PR 4556) makes new and rotated grid keys otlp-only: they may send telemetry and nothing else, + // so every read this server makes would 403. A member PAT bound to one grid is the credential for reading. + + [Test] + public async Task InitializeAsync_Uses_A_Personal_Access_Token_Directly() + { + var gridId = Guid.NewGuid(); + var token = CreateTestJwt(gridId); + + var result = await new Bootstrapper(_securityClient).InitializeAsync(token, null, null); result.GridSecondaryId.Should().Be(gridId); result.AccessToken.Should().Be(token); - result.BaseUrl.Should().Be("https://api-azure.deepcube.ai"); + await _securityClient.DidNotReceive().UnwrapApiKeyAsync(Arg.Any()); } [Test] - public async Task InitializeAsync_Uses_Custom_BaseUrl_When_Provided() + public async Task InitializeAsync_Refuses_A_Token_Not_Bound_To_A_Grid() { + var token = CreateTestJwtWithClaims(new[] { new Claim("scp", "member") }); + + var act = async () => await new Bootstrapper(_securityClient).InitializeAsync(token, null, null); + + await act.Should().ThrowAsync().WithMessage("*not bound to a grid*"); + } + + [Test] + public async Task InitializeAsync_Refuses_A_Token_That_Can_Only_Send_Telemetry() + { + var token = CreateTestJwt(Guid.NewGuid(), scopes: "otlp"); + + var act = async () => await new Bootstrapper(_securityClient).InitializeAsync(token, null, null); + + await act.Should().ThrowAsync().WithMessage("*only send telemetry*IF_Token*"); + } + + [Test] + public async Task InitializeAsync_Refuses_A_Grid_Key_That_Can_Only_Send_Telemetry_And_Points_At_A_Pat() + { + _securityClient.UnwrapApiKeyAsync(Arg.Any()) + .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(CreateTestJwt(Guid.NewGuid(), scopes: "otlp")))); + + var act = async () => await new Bootstrapper(_securityClient).InitializeAsync(null, "new-grid-key", null); + + await act.Should().ThrowAsync().WithMessage("*only send telemetry*personal access token*IF_Token*"); + } + + [Test] + public async Task InitializeAsync_Still_Accepts_A_Grid_Key_Made_Before_The_Change() + { + // Keys created before CR-1 carry member, and keep working until they are rotated. var gridId = Guid.NewGuid(); - var token = CreateTestJwt(gridId); _securityClient.UnwrapApiKeyAsync(Arg.Any()) - .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(token))); + .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(CreateTestJwt(gridId, scopes: "member")))); - var bootstrapper = new Bootstrapper(_securityClient); - var result = await bootstrapper.InitializeAsync("valid-key", "https://custom.api.com"); + var result = await new Bootstrapper(_securityClient).InitializeAsync(null, "old-grid-key", null); - result.BaseUrl.Should().Be("https://custom.api.com"); + result.GridSecondaryId.Should().Be(gridId); + } + + [Test] + public async Task InitializeAsync_Leaves_A_Token_Without_Scopes_To_The_Api() + { + // A token that names no scopes is not narrowed here; the API decides what it may read. + var gridId = Guid.NewGuid(); + var token = CreateTestJwtWithClaims(new[] { new Claim("gsid", gridId.ToString()) }); + + var result = await new Bootstrapper(_securityClient).InitializeAsync(token, null, null); + + result.GridSecondaryId.Should().Be(gridId); + } + + [Test] + public async Task InitializeAsync_Refuses_Both_A_Token_And_A_Key() + { + var act = async () => await new Bootstrapper(_securityClient) + .InitializeAsync(CreateTestJwt(Guid.NewGuid()), "a-key", null); + + await act.Should().ThrowAsync().WithMessage("*not both*"); + } + + [Test] + public async Task InitializeAsync_Refuses_A_Token_That_Is_Not_A_Jwt() + { + var act = async () => await new Bootstrapper(_securityClient).InitializeAsync("dck_p_not-yet", null, null); + + await act.Should().ThrowAsync().WithMessage("*IF_Token*not a personal access token*"); } // ── ValidateToken ──