/**
* Secrets namespace - Key-value storage with versioning
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { MCPServer } from '../core/server.js';
import { NotFoundError, InvalidArgError } from '../core/errors.js';
import {
Secret,
SecretMeta,
PutSecretResponse,
GetSecretResponse,
SecretListItem,
ListSecretsResponse
} from '../types/secrets.js';
export class SecretsNamespace {
private mcpServer: MCPServer;
private secrets = new Map<string, Secret>();
private secretsFile: string;
private encryptionKey: Buffer;
constructor(mcpServer: MCPServer) {
this.mcpServer = mcpServer;
// Initialize secrets storage
const secretsDir = path.join(os.homedir(), '.mcp-fullstack', 'secrets');
if (!fs.existsSync(secretsDir)) {
fs.mkdirSync(secretsDir, { recursive: true });
}
this.secretsFile = path.join(secretsDir, 'secrets.json');
// Generate or load encryption key
const keyFile = path.join(secretsDir, '.key');
if (fs.existsSync(keyFile)) {
this.encryptionKey = fs.readFileSync(keyFile);
} else {
this.encryptionKey = crypto.randomBytes(32);
fs.writeFileSync(keyFile, this.encryptionKey, { mode: 0o600 });
}
this.loadSecrets();
this.registerTools();
}
private loadSecrets(): void {
if (fs.existsSync(this.secretsFile)) {
try {
const encryptedData = fs.readFileSync(this.secretsFile, 'utf-8');
const decryptedData = this.decrypt(encryptedData);
const data = JSON.parse(decryptedData);
for (const [key, secret] of Object.entries(data)) {
this.secrets.set(key, secret as Secret);
}
} catch (error) {
console.error('Failed to load secrets:', error);
// Initialize with empty secrets if loading fails
}
}
}
private saveSecrets(): void {
const data: Record<string, Secret> = {};
for (const [key, secret] of this.secrets) {
// Never persist the actual value in logs
data[key] = secret;
}
const jsonData = JSON.stringify(data, null, 2);
const encryptedData = this.encrypt(jsonData);
fs.writeFileSync(this.secretsFile, encryptedData, { mode: 0o600 });
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
private decrypt(text: string): string {
const parts = text.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encryptedText = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private registerTools(): void {
const registry = this.mcpServer.getRegistry();
registry.registerTool(
'secrets.put',
{
name: 'secrets.put',
description: 'Store a secret value',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string' },
value: { type: 'string' },
meta: {
type: 'object',
properties: {
description: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } }
}
}
},
required: ['key', 'value']
}
},
this.put.bind(this)
);
registry.registerTool(
'secrets.get',
{
name: 'secrets.get',
description: 'Retrieve a secret value',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string' }
},
required: ['key']
}
},
this.get.bind(this)
);
registry.registerTool(
'secrets.list',
{
name: 'secrets.list',
description: 'List secret keys',
inputSchema: {
type: 'object',
properties: {
prefix: { type: 'string' },
limit: { type: 'number' },
cursor: { type: 'string' }
}
}
},
this.list.bind(this)
);
registry.registerTool(
'secrets.delete',
{
name: 'secrets.delete',
description: 'Delete a secret',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string' }
},
required: ['key']
}
},
this.delete.bind(this)
);
}
private async put(params: {
key: string;
value: string;
meta?: SecretMeta;
}): Promise<PutSecretResponse> {
if (!params.key || params.key.trim() === '') {
throw new InvalidArgError('key', 'Key cannot be empty');
}
const existing = this.secrets.get(params.key);
const version = existing ? existing.version + 1 : 1;
const secret: Secret = {
key: params.key,
value: params.value,
version,
meta: params.meta,
updated_at: new Date().toISOString()
};
this.secrets.set(params.key, secret);
this.saveSecrets();
// Log the operation but never the value
console.log(`Secret stored: key="${params.key}", version=${version}`);
return {
ok: true,
version
};
}
private async get(params: {
key: string;
}): Promise<GetSecretResponse> {
const secret = this.secrets.get(params.key);
if (!secret) {
throw new NotFoundError('Secret', params.key);
}
// Log access but never the value
console.log(`Secret accessed: key="${params.key}"`);
return {
key: secret.key,
value: secret.value,
version: secret.version,
meta: secret.meta
};
}
private async list(params: {
prefix?: string;
limit?: number;
cursor?: string;
}): Promise<ListSecretsResponse> {
let keys = Array.from(this.secrets.entries());
// Filter by prefix
if (params.prefix) {
keys = keys.filter(([key]) => key.startsWith(params.prefix!));
}
// Sort by key
keys.sort(([a], [b]) => a.localeCompare(b));
// Apply pagination
const limit = params.limit || 100;
const startIdx = params.cursor ? parseInt(params.cursor) : 0;
const paginatedKeys = keys.slice(startIdx, startIdx + limit);
const hasMore = startIdx + limit < keys.length;
return {
keys: paginatedKeys.map(([key, secret]) => ({
key,
version: secret.version,
updated_at: secret.updated_at
})),
next_cursor: hasMore ? String(startIdx + limit) : undefined
};
}
private async delete(params: {
key: string;
}): Promise<{ ok: true }> {
if (!this.secrets.has(params.key)) {
throw new NotFoundError('Secret', params.key);
}
this.secrets.delete(params.key);
this.saveSecrets();
console.log(`Secret deleted: key="${params.key}"`);
return { ok: true };
}
}