Merge pull request #3996 from jeremymcs/feat/mcp-secure-env-collect
feat(mcp-server): add secure_env_collect tool via MCP elicitation
This commit is contained in:
commit
959f4c53d1
4 changed files with 865 additions and 3 deletions
280
packages/mcp-server/src/env-writer.test.ts
Normal file
280
packages/mcp-server/src/env-writer.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
// @gsd-build/mcp-server — Tests for env-writer utilities
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { describe, it, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
checkExistingEnvKeys,
|
||||
detectDestination,
|
||||
writeEnvKey,
|
||||
applySecrets,
|
||||
isSafeEnvVarKey,
|
||||
isSupportedDeploymentEnvironment,
|
||||
shellEscapeSingle,
|
||||
} from './env-writer.js';
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `${prefix}-`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkExistingEnvKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('checkExistingEnvKeys', () => {
|
||||
it('finds key in .env file', async () => {
|
||||
const tmp = makeTempDir('env-check');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
writeFileSync(envPath, 'API_KEY=secret123\nOTHER=val\n');
|
||||
const result = await checkExistingEnvKeys(['API_KEY'], envPath);
|
||||
assert.deepStrictEqual(result, ['API_KEY']);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('finds key in process.env', async () => {
|
||||
const tmp = makeTempDir('env-check');
|
||||
const saved = process.env.GSD_MCP_TEST_KEY_1;
|
||||
try {
|
||||
process.env.GSD_MCP_TEST_KEY_1 = 'some-value';
|
||||
const envPath = join(tmp, '.env');
|
||||
const result = await checkExistingEnvKeys(['GSD_MCP_TEST_KEY_1'], envPath);
|
||||
assert.deepStrictEqual(result, ['GSD_MCP_TEST_KEY_1']);
|
||||
} finally {
|
||||
delete process.env.GSD_MCP_TEST_KEY_1;
|
||||
if (saved !== undefined) process.env.GSD_MCP_TEST_KEY_1 = saved;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty for missing keys', async () => {
|
||||
const tmp = makeTempDir('env-check');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
writeFileSync(envPath, 'OTHER=val\n');
|
||||
delete process.env.DEFINITELY_NOT_SET_MCP_XYZ;
|
||||
const result = await checkExistingEnvKeys(['DEFINITELY_NOT_SET_MCP_XYZ'], envPath);
|
||||
assert.deepStrictEqual(result, []);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles missing .env file gracefully', async () => {
|
||||
const tmp = makeTempDir('env-check');
|
||||
try {
|
||||
const envPath = join(tmp, 'nonexistent.env');
|
||||
delete process.env.DEFINITELY_NOT_SET_MCP_XYZ;
|
||||
const result = await checkExistingEnvKeys(['DEFINITELY_NOT_SET_MCP_XYZ'], envPath);
|
||||
assert.deepStrictEqual(result, []);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectDestination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectDestination', () => {
|
||||
it('returns vercel when vercel.json exists', () => {
|
||||
const tmp = makeTempDir('dest');
|
||||
try {
|
||||
writeFileSync(join(tmp, 'vercel.json'), '{}');
|
||||
assert.equal(detectDestination(tmp), 'vercel');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns convex when convex/ dir exists', () => {
|
||||
const tmp = makeTempDir('dest');
|
||||
try {
|
||||
mkdirSync(join(tmp, 'convex'));
|
||||
assert.equal(detectDestination(tmp), 'convex');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns dotenv when neither exists', () => {
|
||||
const tmp = makeTempDir('dest');
|
||||
try {
|
||||
assert.equal(detectDestination(tmp), 'dotenv');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('vercel takes priority over convex', () => {
|
||||
const tmp = makeTempDir('dest');
|
||||
try {
|
||||
writeFileSync(join(tmp, 'vercel.json'), '{}');
|
||||
mkdirSync(join(tmp, 'convex'));
|
||||
assert.equal(detectDestination(tmp), 'vercel');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeEnvKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('writeEnvKey', () => {
|
||||
it('creates .env file with new key', async () => {
|
||||
const tmp = makeTempDir('write');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
await writeEnvKey(envPath, 'NEW_KEY', 'new-value');
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
assert.ok(content.includes('NEW_KEY=new-value'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates existing key in-place', async () => {
|
||||
const tmp = makeTempDir('write');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
writeFileSync(envPath, 'EXISTING=old\nOTHER=keep\n');
|
||||
await writeEnvKey(envPath, 'EXISTING', 'new');
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
assert.ok(content.includes('EXISTING=new'));
|
||||
assert.ok(content.includes('OTHER=keep'));
|
||||
assert.ok(!content.includes('old'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('escapes newlines in values', async () => {
|
||||
const tmp = makeTempDir('write');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
await writeEnvKey(envPath, 'MULTI', 'line1\nline2');
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
assert.ok(content.includes('MULTI=line1\\nline2'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-string values', async () => {
|
||||
const tmp = makeTempDir('write');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
await assert.rejects(
|
||||
() => writeEnvKey(envPath, 'KEY', undefined as unknown as string),
|
||||
/expects a string value/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySecrets (dotenv)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applySecrets', () => {
|
||||
const savedKeys: Record<string, string | undefined> = {};
|
||||
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(savedKeys)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it('writes keys to .env and hydrates process.env', async () => {
|
||||
const tmp = makeTempDir('apply');
|
||||
const envPath = join(tmp, '.env');
|
||||
savedKeys.GSD_APPLY_TEST_A = process.env.GSD_APPLY_TEST_A;
|
||||
try {
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: 'GSD_APPLY_TEST_A', value: 'val-a' }],
|
||||
'dotenv',
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
assert.deepStrictEqual(applied, ['GSD_APPLY_TEST_A']);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.equal(process.env.GSD_APPLY_TEST_A, 'val-a');
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
assert.ok(content.includes('GSD_APPLY_TEST_A=val-a'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns errors for invalid vercel environment', async () => {
|
||||
const tmp = makeTempDir('apply');
|
||||
try {
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: 'KEY', value: 'val' }],
|
||||
'vercel',
|
||||
{
|
||||
envFilePath: join(tmp, '.env'),
|
||||
environment: 'staging' as 'development',
|
||||
execFn: async () => ({ code: 0, stderr: '' }),
|
||||
},
|
||||
);
|
||||
assert.deepStrictEqual(applied, []);
|
||||
assert.ok(errors[0]?.includes('unsupported'));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isSafeEnvVarKey', () => {
|
||||
it('accepts valid keys', () => {
|
||||
assert.ok(isSafeEnvVarKey('API_KEY'));
|
||||
assert.ok(isSafeEnvVarKey('_PRIVATE'));
|
||||
assert.ok(isSafeEnvVarKey('key123'));
|
||||
});
|
||||
|
||||
it('rejects invalid keys', () => {
|
||||
assert.ok(!isSafeEnvVarKey('123BAD'));
|
||||
assert.ok(!isSafeEnvVarKey('has-dash'));
|
||||
assert.ok(!isSafeEnvVarKey('has space'));
|
||||
assert.ok(!isSafeEnvVarKey(''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedDeploymentEnvironment', () => {
|
||||
it('accepts valid environments', () => {
|
||||
assert.ok(isSupportedDeploymentEnvironment('development'));
|
||||
assert.ok(isSupportedDeploymentEnvironment('preview'));
|
||||
assert.ok(isSupportedDeploymentEnvironment('production'));
|
||||
});
|
||||
|
||||
it('rejects invalid environments', () => {
|
||||
assert.ok(!isSupportedDeploymentEnvironment('staging'));
|
||||
assert.ok(!isSupportedDeploymentEnvironment('test'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shellEscapeSingle', () => {
|
||||
it('wraps in single quotes', () => {
|
||||
assert.equal(shellEscapeSingle('hello'), "'hello'");
|
||||
});
|
||||
|
||||
it('escapes embedded single quotes', () => {
|
||||
assert.equal(shellEscapeSingle("it's"), "'it'\\''s'");
|
||||
});
|
||||
});
|
||||
183
packages/mcp-server/src/env-writer.ts
Normal file
183
packages/mcp-server/src/env-writer.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// @gsd-build/mcp-server — Environment variable write utilities
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
//
|
||||
// Shared helpers for writing env vars to .env files, detecting project
|
||||
// destinations, and checking existing keys. Used by secure_env_collect
|
||||
// MCP tool. No TUI dependencies — pure filesystem + process.env operations.
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkExistingEnvKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check which keys already exist in a .env file or process.env.
|
||||
* Returns the subset of `keys` that are already set.
|
||||
*/
|
||||
export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
|
||||
let fileContent = "";
|
||||
try {
|
||||
fileContent = await readFile(envFilePath, "utf8");
|
||||
} catch {
|
||||
// ENOENT or other read error — proceed with empty content
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
for (const key of keys) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`^${escaped}\\s*=`, "m");
|
||||
if (regex.test(fileContent) || key in process.env) {
|
||||
existing.push(key);
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectDestination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the write destination based on project files in basePath.
|
||||
* Priority: vercel.json → convex/ dir → fallback "dotenv".
|
||||
*/
|
||||
export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
|
||||
if (existsSync(resolve(basePath, "vercel.json"))) {
|
||||
return "vercel";
|
||||
}
|
||||
const convexPath = resolve(basePath, "convex");
|
||||
try {
|
||||
if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
|
||||
return "convex";
|
||||
}
|
||||
} catch {
|
||||
// stat error — treat as not found
|
||||
}
|
||||
return "dotenv";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeEnvKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a single key=value pair to a .env file.
|
||||
* Updates existing keys in-place, appends new ones at the end.
|
||||
*/
|
||||
export async function writeEnvKey(filePath: string, key: string, value: string): Promise<void> {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`writeEnvKey expects a string value for key "${key}", got ${typeof value}`);
|
||||
}
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
content = "";
|
||||
}
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "");
|
||||
const line = `${key}=${escaped}`;
|
||||
const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, "m");
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, line);
|
||||
} else {
|
||||
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
|
||||
content += `${line}\n`;
|
||||
}
|
||||
await writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSafeEnvVarKey(key: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
|
||||
}
|
||||
|
||||
export function isSupportedDeploymentEnvironment(env: string): boolean {
|
||||
return env === "development" || env === "preview" || env === "production";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell helpers (for vercel/convex CLI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function shellEscapeSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySecrets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApplyResult {
|
||||
applied: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply collected secrets to the target destination.
|
||||
* Dotenv writes are handled directly; vercel/convex shell out via execFn.
|
||||
*/
|
||||
export async function applySecrets(
|
||||
provided: Array<{ key: string; value: string }>,
|
||||
destination: "dotenv" | "vercel" | "convex",
|
||||
opts: {
|
||||
envFilePath: string;
|
||||
environment?: string;
|
||||
execFn?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
||||
},
|
||||
): Promise<ApplyResult> {
|
||||
const applied: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (destination === "dotenv") {
|
||||
for (const { key, value } of provided) {
|
||||
try {
|
||||
await writeEnvKey(opts.envFilePath, key, value);
|
||||
applied.push(key);
|
||||
// Hydrate process.env so the current session sees the new value
|
||||
process.env[key] = value;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`${key}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((destination === "vercel" || destination === "convex") && opts.execFn) {
|
||||
const env = opts.environment ?? "development";
|
||||
if (!isSupportedDeploymentEnvironment(env)) {
|
||||
errors.push(`environment: unsupported target environment "${env}"`);
|
||||
return { applied, errors };
|
||||
}
|
||||
for (const { key, value } of provided) {
|
||||
if (!isSafeEnvVarKey(key)) {
|
||||
errors.push(`${key}: invalid environment variable name`);
|
||||
continue;
|
||||
}
|
||||
const cmd = destination === "vercel"
|
||||
? `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`
|
||||
: "";
|
||||
try {
|
||||
const result = destination === "vercel"
|
||||
? await opts.execFn("sh", ["-c", cmd])
|
||||
: await opts.execFn("npx", ["convex", "env", "set", key, value]);
|
||||
if (result.code !== 0) {
|
||||
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
||||
} else {
|
||||
applied.push(key);
|
||||
process.env[key] = value;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`${key}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { applied, errors };
|
||||
}
|
||||
265
packages/mcp-server/src/secure-env-collect.test.ts
Normal file
265
packages/mcp-server/src/secure-env-collect.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// @gsd-build/mcp-server — Tests for secure_env_collect MCP tool
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
//
|
||||
// Tests the secure_env_collect tool registered in createMcpServer.
|
||||
// Uses a mock MCP server to intercept tool registration and elicitInput calls.
|
||||
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createMcpServer } from './server.js';
|
||||
import { SessionManager } from './session-manager.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* We intercept McpServer construction by monkey-patching the dynamic import.
|
||||
* Instead, we'll test the tool handler indirectly through the exported
|
||||
* createMcpServer function — capturing the registered tool handlers.
|
||||
*
|
||||
* Since createMcpServer dynamically imports McpServer, we need to test at
|
||||
* a level that exercises the tool handler logic. We do this by extracting
|
||||
* the tool handler through the server.tool() calls.
|
||||
*/
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
description: string;
|
||||
params: Record<string, unknown>;
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
content?: Array<{ type: string; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock McpServer that captures tool registrations and provides
|
||||
* a controllable elicitInput response.
|
||||
*/
|
||||
class MockMcpServer {
|
||||
registeredTools: RegisteredTool[] = [];
|
||||
elicitResponse: { action: string; content?: Record<string, unknown> } = { action: 'accept', content: {} };
|
||||
|
||||
server = {
|
||||
elicitInput: async (_params: unknown) => {
|
||||
return this.elicitResponse;
|
||||
},
|
||||
};
|
||||
|
||||
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>) {
|
||||
this.registeredTools.push({ name, description, params, handler });
|
||||
}
|
||||
|
||||
async connect(_transport: unknown) { /* no-op */ }
|
||||
async close() { /* no-op */ }
|
||||
|
||||
getToolHandler(name: string): ((args: Record<string, unknown>) => Promise<unknown>) | undefined {
|
||||
return this.registeredTools.find((t) => t.name === name)?.handler;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to create a mock MCP server with secure_env_collect registered
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Since createMcpServer uses dynamic import for McpServer, we can't easily
|
||||
* mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts)
|
||||
* and test the tool integration by verifying:
|
||||
* 1. The tool exists in the registered tools list
|
||||
* 2. The handler produces correct results with mock data
|
||||
*
|
||||
* For handler-level testing, we create a standalone test that replicates
|
||||
* the tool handler logic with a controllable mock.
|
||||
*/
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `${prefix}-`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration test — verify tool is registered
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('secure_env_collect tool registration', () => {
|
||||
it('createMcpServer registers secure_env_collect tool', async () => {
|
||||
// This test verifies the tool exists — createMcpServer internally calls
|
||||
// server.tool('secure_env_collect', ...) which we can't intercept without
|
||||
// module mocking, but we can verify the server creates successfully
|
||||
const sm = new SessionManager();
|
||||
try {
|
||||
const { server } = await createMcpServer(sm);
|
||||
assert.ok(server, 'server should be created');
|
||||
// The McpServer internally tracks registered tools — we verify no error
|
||||
} finally {
|
||||
await sm.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler logic tests — using env-writer directly to test the flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('secure_env_collect handler logic', () => {
|
||||
it('skips keys that already exist in .env', async () => {
|
||||
const tmp = makeTempDir('sec-collect');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
writeFileSync(envPath, 'ALREADY_SET=existing-value\n');
|
||||
|
||||
// Import the utility directly to test the pre-check logic
|
||||
const { checkExistingEnvKeys } = await import('./env-writer.js');
|
||||
const existing = await checkExistingEnvKeys(['ALREADY_SET', 'NEW_KEY'], envPath);
|
||||
assert.deepStrictEqual(existing, ['ALREADY_SET']);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes collected values to .env without returning secret values', async () => {
|
||||
const tmp = makeTempDir('sec-collect');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
const savedKey = process.env.SEC_COLLECT_TEST_KEY;
|
||||
|
||||
const { applySecrets } = await import('./env-writer.js');
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: 'SEC_COLLECT_TEST_KEY', value: 'super-secret-value' }],
|
||||
'dotenv',
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(applied, ['SEC_COLLECT_TEST_KEY']);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
|
||||
// Verify the value was written
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
assert.ok(content.includes('SEC_COLLECT_TEST_KEY=super-secret-value'));
|
||||
|
||||
// Verify process.env was hydrated
|
||||
assert.equal(process.env.SEC_COLLECT_TEST_KEY, 'super-secret-value');
|
||||
|
||||
// Cleanup
|
||||
if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY;
|
||||
else process.env.SEC_COLLECT_TEST_KEY = savedKey;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-detects vercel destination from vercel.json', async () => {
|
||||
const tmp = makeTempDir('sec-collect');
|
||||
try {
|
||||
writeFileSync(join(tmp, 'vercel.json'), '{}');
|
||||
const { detectDestination } = await import('./env-writer.js');
|
||||
assert.equal(detectDestination(tmp), 'vercel');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles empty form values as skipped', async () => {
|
||||
// Simulate what happens when user leaves a field empty in the form
|
||||
const formContent: Record<string, string> = {
|
||||
'API_KEY': 'provided-value',
|
||||
'OPTIONAL_KEY': '', // empty = skip
|
||||
};
|
||||
|
||||
const provided: Array<{ key: string; value: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const [key, raw] of Object.entries(formContent)) {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (value.length > 0) {
|
||||
provided.push({ key, value });
|
||||
} else {
|
||||
skipped.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(provided, [{ key: 'API_KEY', value: 'provided-value' }]);
|
||||
assert.deepStrictEqual(skipped, ['OPTIONAL_KEY']);
|
||||
});
|
||||
|
||||
it('result text never contains secret values', async () => {
|
||||
const tmp = makeTempDir('sec-collect');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
const savedKey = process.env.RESULT_TEXT_TEST;
|
||||
|
||||
const { applySecrets } = await import('./env-writer.js');
|
||||
const { applied } = await applySecrets(
|
||||
[{ key: 'RESULT_TEXT_TEST', value: 'sk-super-secret-abc123' }],
|
||||
'dotenv',
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
|
||||
// Simulate building result text (same logic as the tool handler)
|
||||
const lines: string[] = [
|
||||
'destination: dotenv (auto-detected)',
|
||||
...applied.map((k) => `✓ ${k}: applied`),
|
||||
];
|
||||
const resultText = lines.join('\n');
|
||||
|
||||
// The result MUST NOT contain the secret value
|
||||
assert.ok(!resultText.includes('sk-super-secret-abc123'), 'result text must not contain secret value');
|
||||
assert.ok(resultText.includes('RESULT_TEXT_TEST'), 'result text should contain key name');
|
||||
|
||||
// Cleanup
|
||||
if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST;
|
||||
else process.env.RESULT_TEXT_TEST = savedKey;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles multiple keys with mixed existing/new/skipped', async () => {
|
||||
const tmp = makeTempDir('sec-collect');
|
||||
try {
|
||||
const envPath = join(tmp, '.env');
|
||||
writeFileSync(envPath, 'EXISTING_A=already-here\n');
|
||||
const savedB = process.env.NEW_B;
|
||||
const savedC = process.env.SKIP_C;
|
||||
|
||||
const { checkExistingEnvKeys, applySecrets } = await import('./env-writer.js');
|
||||
|
||||
const allKeys = ['EXISTING_A', 'NEW_B', 'SKIP_C'];
|
||||
const existing = await checkExistingEnvKeys(allKeys, envPath);
|
||||
assert.deepStrictEqual(existing, ['EXISTING_A']);
|
||||
|
||||
// Simulate form response: NEW_B has value, SKIP_C is empty
|
||||
const formContent = { NEW_B: 'new-value', SKIP_C: '' };
|
||||
const provided: Array<{ key: string; value: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const key of allKeys.filter((k) => !existing.includes(k))) {
|
||||
const raw = formContent[key as keyof typeof formContent] ?? '';
|
||||
if (raw.trim().length > 0) provided.push({ key, value: raw.trim() });
|
||||
else skipped.push(key);
|
||||
}
|
||||
|
||||
const { applied, errors } = await applySecrets(provided, 'dotenv', { envFilePath: envPath });
|
||||
|
||||
assert.deepStrictEqual(applied, ['NEW_B']);
|
||||
assert.deepStrictEqual(skipped, ['SKIP_C']);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(existing, ['EXISTING_A']);
|
||||
|
||||
// Cleanup
|
||||
if (savedB === undefined) delete process.env.NEW_B;
|
||||
else process.env.NEW_B = savedB;
|
||||
if (savedC === undefined) delete process.env.SKIP_C;
|
||||
else process.env.SKIP_C = savedC;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* MCP Server — registers GSD orchestration, project-state, and workflow tools.
|
||||
*
|
||||
* Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
|
||||
* Interactive tools (1): ask_user_questions via MCP form elicitation
|
||||
* Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation
|
||||
* Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
|
||||
* Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
|
||||
*
|
||||
|
|
@ -22,6 +22,7 @@ import { readCaptures } from './readers/captures.js';
|
|||
import { readKnowledge } from './readers/knowledge.js';
|
||||
import { runDoctorLite } from './readers/doctor-lite.js';
|
||||
import { registerWorkflowTools } from './workflow-tools.js';
|
||||
import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
|
|
@ -112,11 +113,26 @@ async function fileExists(path: string): Promise<boolean> {
|
|||
// MCP Server type — minimal interface for the dynamically-imported McpServer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ElicitResult {
|
||||
action: 'accept' | 'decline' | 'cancel';
|
||||
content?: Record<string, string | number | boolean | string[]>;
|
||||
}
|
||||
|
||||
interface ElicitRequestFormParams {
|
||||
mode?: 'form';
|
||||
message: string;
|
||||
requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, Record<string, unknown>>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface McpServerInstance {
|
||||
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
|
||||
server: {
|
||||
elicitInput(
|
||||
params: AskUserQuestionsElicitRequest,
|
||||
params: AskUserQuestionsElicitRequest | ElicitRequestFormParams,
|
||||
options?: unknown,
|
||||
): Promise<AskUserQuestionsElicitResult>;
|
||||
};
|
||||
|
|
@ -282,7 +298,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
|
|||
|
||||
const server: McpServerInstance = new McpServer(
|
||||
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
{ capabilities: { tools: {}, elicitation: {} } },
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -472,6 +488,124 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
|
|||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// secure_env_collect — collect secrets via MCP form elicitation
|
||||
// -----------------------------------------------------------------------
|
||||
server.tool(
|
||||
'secure_env_collect',
|
||||
'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.',
|
||||
{
|
||||
projectDir: z.string().describe('Absolute path to the project directory'),
|
||||
keys: z.array(z.object({
|
||||
key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'),
|
||||
hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'),
|
||||
guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'),
|
||||
})).min(1).describe('Environment variables to collect'),
|
||||
destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'),
|
||||
envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'),
|
||||
environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'),
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const { projectDir, keys, destination, envFilePath, environment } = args as {
|
||||
projectDir: string;
|
||||
keys: Array<{ key: string; hint?: string; guidance?: string[] }>;
|
||||
destination?: 'dotenv' | 'vercel' | 'convex';
|
||||
envFilePath?: string;
|
||||
environment?: 'development' | 'preview' | 'production';
|
||||
};
|
||||
|
||||
try {
|
||||
const resolvedProjectDir = resolve(projectDir);
|
||||
const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env');
|
||||
|
||||
// (1) Check which keys already exist
|
||||
const allKeyNames = keys.map((k) => k.key);
|
||||
const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath);
|
||||
const existingSet = new Set(existingKeys);
|
||||
const pendingKeys = keys.filter((k) => !existingSet.has(k.key));
|
||||
|
||||
// If all keys already exist, return immediately
|
||||
if (pendingKeys.length === 0) {
|
||||
const lines = existingKeys.map((k) => `• ${k}: already set`);
|
||||
return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`);
|
||||
}
|
||||
|
||||
// (2) Build elicitation form — one string field per pending key
|
||||
const properties: Record<string, Record<string, unknown>> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const item of pendingKeys) {
|
||||
const descParts: string[] = [];
|
||||
if (item.hint) descParts.push(`Format: ${item.hint}`);
|
||||
if (item.guidance && item.guidance.length > 0) {
|
||||
descParts.push('How to get this:');
|
||||
item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`));
|
||||
}
|
||||
descParts.push('Leave empty to skip.');
|
||||
|
||||
properties[item.key] = {
|
||||
type: 'string',
|
||||
title: item.key,
|
||||
description: descParts.join('\n'),
|
||||
};
|
||||
// Don't mark as required — empty string = skip
|
||||
}
|
||||
|
||||
// (3) Elicit input from the MCP client
|
||||
const elicitation = await server.server.elicitInput({
|
||||
message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
required,
|
||||
},
|
||||
});
|
||||
|
||||
if (elicitation.action !== 'accept' || !elicitation.content) {
|
||||
return textContent('secure_env_collect was cancelled by user.');
|
||||
}
|
||||
|
||||
// (4) Separate provided vs skipped from form response
|
||||
const provided: Array<{ key: string; value: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const item of pendingKeys) {
|
||||
const raw = elicitation.content[item.key];
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (value.length > 0) {
|
||||
provided.push({ key: item.key, value });
|
||||
} else {
|
||||
skipped.push(item.key);
|
||||
}
|
||||
}
|
||||
|
||||
// (5) Auto-detect destination if not specified
|
||||
const resolvedDestination = destination ?? detectDestination(resolvedProjectDir);
|
||||
|
||||
// (6) Write secrets to destination
|
||||
const { applied, errors } = await applySecrets(provided, resolvedDestination, {
|
||||
envFilePath: resolvedEnvPath,
|
||||
environment,
|
||||
});
|
||||
|
||||
// (7) Build result — NEVER include secret values
|
||||
const lines: string[] = [
|
||||
`destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`,
|
||||
];
|
||||
for (const k of applied) lines.push(`✓ ${k}: applied`);
|
||||
for (const k of skipped) lines.push(`• ${k}: skipped`);
|
||||
for (const k of existingKeys) lines.push(`• ${k}: already set`);
|
||||
for (const e of errors) lines.push(`✗ ${e}`);
|
||||
|
||||
return errors.length > 0 && applied.length === 0
|
||||
? errorContent(lines.join('\n'))
|
||||
: textContent(lines.join('\n'));
|
||||
} catch (err) {
|
||||
return errorContent(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =======================================================================
|
||||
// READ-ONLY TOOLS — no session required, pure filesystem reads
|
||||
// =======================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue