Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 </dev/null 2>&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 </dev/null 2>&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
6 changes: 3 additions & 3 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"inputs": [
{
"type": "promptString",
"id": "if-key",
"description": "DeepCube API Key",
"id": "if-token",
"description": "DeepCube personal access token",
"password": true
}
],
Expand All @@ -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}"
}
}
}
Expand Down
49 changes: 30 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
],
Expand All @@ -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}"
}
}
}
Expand All @@ -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=<your-key> -- ~/.local/bin/dcp
claude mcp add --scope local deepcube -e IF_Token=<your-token> -- ~/.local/bin/dcp
```

**Claude Desktop, Cursor, and other clients** take the same shape in their own config
Expand All @@ -107,13 +117,13 @@ file:
"mcpServers": {
"deepcube": {
"command": "/absolute/path/to/dcp",
"env": { "IF_ApiKey": "<your-key>" }
"env": { "IF_Token": "<your-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
Expand All @@ -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

Expand Down
85 changes: 78 additions & 7 deletions src/IF.APM.App.MCP.Server/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,77 @@ public class BootstrapResult

public class Bootstrapper
{
/// <summary>The API the server reads from unless <c>IF_BaseUrl</c> names another.</summary>
public const string DefaultBaseUrl = "https://web.deepcube.ai";

/// <summary>Returns <paramref name="baseUrl"/> when it is set, else <see cref="DefaultBaseUrl"/>.</summary>
public static string ResolveBaseUrl(string? baseUrl) =>
string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl.Trim().TrimEnd('/');

/// <summary>The scope a token needs to read a grid's telemetry.</summary>
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)
{
_securityClient = securityClient;
}

public async Task<BootstrapResult> InitializeAsync(string? apiKey, string? baseUrl)
/// <summary>
/// Resolves the credential this server reads with: a personal access token (<c>IF_Token</c>), used as it is, or
/// a grid API key (<c>IF_ApiKey</c>), exchanged for its token.
/// </summary>
/// <remarks>
/// A grid API key created or rotated after CR-1 may only send telemetry (scope <c>otlp</c>), 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 <c>member</c> and keep working.
/// </remarks>
public async Task<BootstrapResult> 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
Expand All @@ -46,6 +96,27 @@ public async Task<BootstrapResult> InitializeAsync(string? apiKey, string? baseU
};
}

/// <summary>
/// Refuses a token that names its scopes and does not include <see cref="MemberScope"/>: it cannot read. A token
/// that names no scopes is left to the API to decide.
/// </summary>
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();
Expand All @@ -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)
{
Expand Down
28 changes: 25 additions & 3 deletions src/IF.APM.App.MCP.Server/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
{
Expand All @@ -49,6 +70,7 @@

// Build and run the host. This starts the MCP server.
await builder.Build().RunAsync();
return 0;

public class GridAnchor
{
Expand Down
Loading
Loading