Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Unreleased Changes

_No unreleased changes yet._
## 0.9.1

- Replaced file-based token cache with an in-memory cache to avoid writing credentials to disk. Tokens now reset on server restart.

## 0.9.0

Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dynatrace-mcp-server",
"version": "0.9.0",
"version": "0.9.1",
"mcpServers": {
"dynatrace": {
"command": "npx",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dynatrace-oss/dynatrace-mcp-server",
"version": "0.9.0",
"version": "0.9.1",
"mcpName": "io.github.dynatrace-oss/Dynatrace-mcp",
"description": "Model Context Protocol (MCP) server for Dynatrace",
"keywords": [
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
"url": "https://github.com/dynatrace-oss/Dynatrace-mcp",
"source": "github"
},
"version": "0.9.0",
"version": "0.9.1",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@dynatrace-oss/dynatrace-mcp-server",
"version": "0.9.0",
"version": "0.9.1",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
Expand Down
3 changes: 2 additions & 1 deletion src/authentication/dynatrace-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ const createOAuthClientCredentialsHttpClient = async (

/** Create an OAuth Client using authorization code flow (interactive authentication)
* This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
* Implements token caching (via .dt-mcp/token.json) to avoid repeated OAuth flows.
* Implements an in-memory token cache (not persisted to disk). After every server restart a new
* authentication flow (or token refresh) may be required.
* Note: Always requests a complete set of scopes for maximum token reusability. Else the user will end up having to approve multiple requests.
*/
const createOAuthAuthCodeFlowHttpClient = async (
Expand Down
92 changes: 12 additions & 80 deletions src/authentication/token-cache.ts
Original file line number Diff line number Diff line change
@@ -1,124 +1,56 @@
import { CachedToken, TokenCache, OAuthTokenResponse } from './types';
import * as fs from 'fs';
import * as path from 'path';

/**
* File-based token cache implementation that persists tokens to disk
* Stores tokens in .dt-mcp/token.json for persistence across dynatrace-mcp-server restarts
* In-memory token cache implementation (no persistence across process restarts).
* The previous implementation stored tokens on disk in `.dt-mcp/token.json` – this has been
* intentionally removed to avoid writing credentials to the local filesystem. A new login /
* OAuth authorization code flow (or token retrieval) will be required after every server restart.
*/
export class FileTokenCache implements TokenCache {
private readonly tokenFilePath: string;
export class InMemoryTokenCache implements TokenCache {
private token: CachedToken | null = null;

constructor() {
// Create .dt-mcp directory in the current working directory
const tokenDir = path.join(process.cwd(), '.dt-mcp');
this.tokenFilePath = path.join(tokenDir, 'token.json');

// Ensure the directory exists
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, { recursive: true });
}

this.loadToken();
}

/**
* Loads the token from the file system
*/
private loadToken(): void {
try {
if (fs.existsSync(this.tokenFilePath)) {
const tokenData = fs.readFileSync(this.tokenFilePath, 'utf8');
this.token = JSON.parse(tokenData);
console.error(`🔍 Loaded token from file: ${this.tokenFilePath}`);
} else {
console.error(`🔍 No token file found at: ${this.tokenFilePath}`);
this.token = null;
}
} catch (error) {
console.error(`❌ Failed to load token from file: ${error}`);
this.token = null;
}
}

/**
* Saves the token to the file system
*/
private saveToken(): void {
try {
if (this.token) {
fs.writeFileSync(this.tokenFilePath, JSON.stringify(this.token, null, 2), 'utf8');
console.error(`✅ Saved token to file: ${this.tokenFilePath}`);
} else {
// Remove the file if no token exists
if (fs.existsSync(this.tokenFilePath)) {
fs.unlinkSync(this.tokenFilePath);
console.error(`🗑️ Removed token file: ${this.tokenFilePath}`);
}
}
} catch (error) {
console.error(`❌ Failed to save token to file: ${error}`);
}
}

/**
* Retrieves the cached token (ignores scopes since we use a global token)
*/
getToken(scopes: string[]): CachedToken | null {
// We ignore the scopes parameter since we use a single token with all scopes
// Scopes parameter ignored – single global token covers all requested scopes.
return this.token;
}

/**
* Stores the global token in the cache and persists it to file
*/
setToken(scopes: string[], token: OAuthTokenResponse): void {
// We ignore the scopes parameter since we use a single token with all scopes
this.token = {
access_token: token.access_token!,
refresh_token: token.refresh_token,
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
scopes: [...scopes], // Store the actual scopes that were granted
scopes: [...scopes],
};

this.saveToken();
}

/**
* Removes the cached token and deletes the file
*/
clearToken(scopes?: string[]): void {
// We ignore the scopes parameter since we use a single global token
this.token = null;
this.saveToken();
}

/**
* Checks if the token exists and is still valid (not expired)
*/
isTokenValid(scopes: string[]): boolean {
// We ignore the scopes parameter since we use a single token with all scopes
if (!this.token) {
console.error(`🔍 Token validation: No token in cache`);
return false;
}

// If no expiration time is set, assume token is valid
if (!this.token.expires_at) {
console.error(`🔍 Token validation: Token has no expiration, assuming valid`);
return true;
}
if (!this.token) return false;
if (!this.token.expires_at) return true; // treat as non-expiring

// Add a 30-second buffer to avoid using tokens that are about to expire
const bufferMs = 30 * 1000; // 30 seconds
const now = Date.now();
const expiresAt = this.token.expires_at;
const isValid = now + bufferMs < expiresAt;

return isValid;
return now + bufferMs < expiresAt;
}
}

// Global token cache instance - uses file-based persistence
export const globalTokenCache = new FileTokenCache();
// Global token cache instance - In-memory only
export const globalTokenCache = new InMemoryTokenCache();