From 71ca5a6d08fb4ee79cc85594d614e03808f53534 Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Mon, 12 Oct 2026 23:59:41 -0400 Subject: [PATCH 1/5] fix(auth): read with a personal access token; refuse a grid key that can only send telemetry DeepCube's CR-1 (App.Http PR 4556) makes grid API keys created or rotated from now on otlp-only: they may send telemetry and nothing else. This server exchanged a grid key for a token at startup and made every read with it, so with a new or rotated key all 25 read calls would return 403. - IF_Token: a personal access token bound to one grid (scope member, claim gsid), used as it is with no exchange. This is the credential to use from now on. - IF_ApiKey still works for keys created before CR-1, which carry member, until they are rotated. - A token that names its scopes without member is refused at startup, with what to use instead, rather than failing every tool call later. A token not bound to a grid is refused the same way. A token that names no scopes is left to the API. - Setting both, or neither, is refused with a message naming IF_Token. - README and the dev .vscode/mcp.json use IF_Token. Tests first: eight new bootstrapper cases. The old "throws" tests never awaited their assertion and so could not fail; they are now one awaited case per blank value. With the scope check removed, the three refusal tests fail; restored, all 94 pass. Co-Authored-By: Claude Opus 5.5 --- .vscode/mcp.json | 6 +- README.md | 41 +++--- src/IF.APM.App.MCP.Server/Bootstrapper.cs | 76 +++++++++- src/IF.APM.App.MCP.Server/Program.cs | 4 +- .../BootstrapperTests.cs | 135 +++++++++++++----- 5 files changed, 203 insertions(+), 59 deletions(-) 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..cedc97d 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,21 @@ 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. Create it +on the grid's page in the [DeepCube portal](https://portal.deepcube.ai), under +**Personal access tokens**, and copy it when it is shown: it is shown once. 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. +**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 +71,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: +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 +92,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 +102,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,7 +113,7 @@ file: "mcpServers": { "deepcube": { "command": "/absolute/path/to/dcp", - "env": { "IF_ApiKey": "" } + "env": { "IF_Token": "" } } } } @@ -124,12 +130,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..de1b8a8 100644 --- a/src/IF.APM.App.MCP.Server/Bootstrapper.cs +++ b/src/IF.APM.App.MCP.Server/Bootstrapper.cs @@ -15,6 +15,15 @@ public class BootstrapResult public class Bootstrapper { + /// 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 +31,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 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 +89,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 +130,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..2d4fa79 100644 --- a/src/IF.APM.App.MCP.Server/Program.cs +++ b/src/IF.APM.App.MCP.Server/Program.cs @@ -18,6 +18,8 @@ consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); +// IF_Token: a personal access token for one grid. IF_ApiKey: a grid API key (see Bootstrapper). +var token = builder.Configuration["Token"]; var apiKey = builder.Configuration["ApiKey"]; var baseUrl = builder.Configuration["BaseUrl"]; @@ -27,7 +29,7 @@ }, new HttpClient()); var bootstrapper = new Bootstrapper(securityClient); -var result = await bootstrapper.InitializeAsync(apiKey, baseUrl); +var result = await bootstrapper.InitializeAsync(token, apiKey, baseUrl); var config = new ApiClientConfiguration { diff --git a/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs b/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs index f2c92c4..7173197 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,67 +54,138 @@ 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://api-azure.deepcube.ai"); } [Test] - public void InitializeAsync_Throws_When_ApiKey_Is_Whitespace() + public async Task InitializeAsync_Uses_Custom_BaseUrl_When_Provided() { - 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", "https://custom.api.com"); - act.Should().ThrowAsync() - .WithMessage("*API Key*"); + 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_Returns_Result_With_Valid_Token() + public async Task InitializeAsync_Uses_A_Personal_Access_Token_Directly() { var gridId = Guid.NewGuid(); var token = CreateTestJwt(gridId); - _securityClient.UnwrapApiKeyAsync(Arg.Any()) - .Returns(Task.FromResult(new UnwrappedApiKeyViewModel(token))); - var bootstrapper = new Bootstrapper(_securityClient); - var result = await bootstrapper.InitializeAsync("valid-key", null); + 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 ── From 34a01ea7a4f6772066e8f89ae467bcbcf4f79997 Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Wed, 4 Nov 2026 16:40:04 -0500 Subject: [PATCH 2/5] fix: default to web.deepcube.ai, and fail with one plain line instead of a stack trace api-azure.deepcube.ai no longer resolves (retired 2026-11-04), so every client on the defaults crashed at start and the MCP client only showed "connection closed". web.deepcube.ai is the single API host. IF_BaseUrl still overrides it; a blank value now means unset, and a trailing slash is trimmed. A missing or unusable credential, or an unreachable API, now prints one line on stderr naming the host or the fix, and exits 1. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/IF.APM.App.MCP.Server/Bootstrapper.cs | 9 ++++++- src/IF.APM.App.MCP.Server/Program.cs | 26 ++++++++++++++++--- .../BootstrapperTests.cs | 19 +++++++++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cedc97d..ce88233 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ file: } ``` -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 diff --git a/src/IF.APM.App.MCP.Server/Bootstrapper.cs b/src/IF.APM.App.MCP.Server/Bootstrapper.cs index de1b8a8..3fd38ac 100644 --- a/src/IF.APM.App.MCP.Server/Bootstrapper.cs +++ b/src/IF.APM.App.MCP.Server/Bootstrapper.cs @@ -15,6 +15,13 @@ 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"; @@ -56,7 +63,7 @@ public async Task InitializeAsync(string? token, string? apiKey $"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); string accessToken; string source; diff --git a/src/IF.APM.App.MCP.Server/Program.cs b/src/IF.APM.App.MCP.Server/Program.cs index 2d4fa79..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; @@ -19,17 +20,35 @@ }); // 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(token, 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 { @@ -51,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 7173197..8807324 100644 --- a/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs +++ b/tests/IF.APM.App.MCP.Server.UnitTests/BootstrapperTests.cs @@ -80,7 +80,24 @@ public async Task InitializeAsync_Returns_Result_With_Valid_Token() result.GridSecondaryId.Should().Be(gridId); result.AccessToken.Should().Be(token); - result.BaseUrl.Should().Be("https://api-azure.deepcube.ai"); + result.BaseUrl.Should().Be("https://web.deepcube.ai"); + } + + // 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) + { + Bootstrapper.ResolveBaseUrl(baseUrl).Should().Be(Bootstrapper.DefaultBaseUrl); + Bootstrapper.DefaultBaseUrl.Should().Be("https://web.deepcube.ai"); + } + + [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] From 23db2273e8195f7cc6aceac8d3094bbda43c7287 Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Wed, 4 Nov 2026 16:43:57 -0500 Subject: [PATCH 3/5] ci: check the startup credential message this branch prints The package gate grepped for "API Key was not provided", which this branch reworded to "No credential was provided", so the gate failed from the first commit. It now also checks exit code 1 and that the failure is one plain line, not a stack trace. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 From 8c9d17d17d674533187a5c2bf272a56f9384fa51 Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Wed, 4 Nov 2026 16:52:21 -0500 Subject: [PATCH 4/5] docs(readme): how to create a personal access token until the portal can The portal has no screen for personal access tokens yet (PBI 3704), so step 1 pointed at something that does not exist. It now shows the interim route: sign in on the API help page and call POST /tokens/personal-access with scope member, the grid's ID and a 30-day lifetime (90 days is the most allowed). Co-Authored-By: Claude Opus 5.5 --- README.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ce88233..6e0d3d3 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,30 @@ that runs it. ### 1. Create a personal access token -The server reads one grid with a **personal access token** bound to that grid. Create it -on the grid's page in the [DeepCube portal](https://portal.deepcube.ai), under -**Personal access tokens**, and copy it when it is shown: it is shown once. A token can +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 portal does not have a screen for creating these tokens yet. Until it does, create +one from the API's help page: + +1. Open [web.deepcube.ai/help](https://web.deepcube.ai/help), choose **Authorize**, and + sign in with your DeepCube account. +2. Find `POST /tokens/personal-access`, choose **Try it out**, and send this body, with + your grid's ID: + + ```json + { + "scopes": ["member"], + "gridSecondaryId": "", + "expirationInSeconds": 2592000 + } + ``` + + `member` lets the token read. `2592000` seconds is 30 days, the recommended + lifetime; the most the API allows is 90 days. +3. Copy the token from the response. It is not shown again. + **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 From 2c2acd880225e2b4734b721ff4a79db5f40c9089 Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Wed, 4 Nov 2026 20:32:35 -0500 Subject: [PATCH 5/5] docs(readme): replace the help-page token route, which does not work The API help page has no sign-in (no security scheme, empty OAuth config), so the route added in 8c9d17d could not be followed. Say what is true until the portal token screen ships (PBI 3704): a grid key created before October 2026 still reads through IF_ApiKey; otherwise ask support for a token. Also drop the last em dash. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6e0d3d3..c9ed291 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,11 @@ The server reads one grid with a **personal access token** bound to that grid. A 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 portal does not have a screen for creating these tokens yet. Until it does, create -one from the API's help page: - -1. Open [web.deepcube.ai/help](https://web.deepcube.ai/help), choose **Authorize**, and - sign in with your DeepCube account. -2. Find `POST /tokens/personal-access`, choose **Try it out**, and send this body, with - your grid's ID: - - ```json - { - "scopes": ["member"], - "gridSecondaryId": "", - "expirationInSeconds": 2592000 - } - ``` - - `member` lets the token read. `2592000` seconds is 30 days, the recommended - lifetime; the most the API allows is 90 days. -3. Copy the token from the response. It is not shown again. +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 @@ -92,7 +78,7 @@ xattr -d com.apple.quarantine ~/.local/bin/dcp 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 +**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