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
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,24 @@ Tools marked **destructive** below are irreversible or high-impact (spend quota,

### `@ferrvault/api-mcp`

Targets the FerrVault API (`api.ferrvault.com`, or a self-hosted instance through `FERRVAULT_API_URL`): vaults, their environments, and the secrets and secret requests of each environment. Vaults belong to the organization the token is signed in to.
Targets the FerrVault API (`api.ferrvault.com`, or a self-hosted instance through `FERRVAULT_API_URL`): vaults, their environments, and the secrets, secret requests and service tokens of each environment. Vaults belong to the organization the token is signed in to.

The FerrVault API accepts FerrLabs session JWTs, not `fft_*` API tokens. Over HTTP, register the client with the IdP as an `eddsa_session_jwt` OAuth client and pass its id to the MCP client (`claude mcp add --client-id`); a dynamically registered client gets an `fft_*` token, which the API refuses.

| Tool | Notes |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `list_ferrvault_vaults`, `list_ferrvault_environments` | read |
| `list_ferrvault_secrets` | names, versions, tags and expiry; never values |
| `reveal_ferrvault_secret` | returns the plaintext value, audit-logged by FerrVault |
| `set_ferrvault_secret` | creates the secret, or stores a new version when it exists |
| `generate_ferrvault_secret` | random value generated in the MCP server, stored like `set_ferrvault_secret`, never returned |
| `delete_ferrvault_secret` | **destructive** |
| `list_ferrvault_secret_requests` | pending requests only: names clients asked for that have no value, with counts, timestamps and requester kind |
| `archive_ferrvault_secret_request` | by id, or by name (resolved to the pending request); stays archived even if clients keep asking |
| Tool | Notes |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_ferrvault_vaults`, `list_ferrvault_environments` | read |
| `list_ferrvault_secrets` | names, versions, tags and expiry; never values |
| `reveal_ferrvault_secret` | returns the plaintext value, audit-logged by FerrVault |
| `set_ferrvault_secret` | creates the secret, or stores a new version when it exists |
| `generate_ferrvault_secret` | random value generated in the MCP server, stored like `set_ferrvault_secret`, never returned |
| `delete_ferrvault_secret` | **destructive** |
| `list_ferrvault_secret_requests` | pending requests only: names clients asked for that have no value, with counts, timestamps and requester kind |
| `archive_ferrvault_secret_request` | by id, or by name (resolved to the pending request); stays archived even if clients keep asking |
| `copy_ferrvault_secret` | copies a value to another vault, environment or name (name defaults to the source), stored like `set_ferrvault_secret`; read and written in the MCP server, never returned; refuses source equal to target |
| `list_ferrvault_service_tokens` | operator tokens of one environment: id, name, role, creator, created, expiry, last use; never the token; vault admin |
| `create_ferrvault_service_token` | name, role, optional expiry; the token is written into a target secret (created or versioned) and never returned, and revoked again if that write fails; vault admin |
| `revoke_ferrvault_service_token` | by id; **destructive**, workloads using it lose access; vault admin |

### `@ferrtrack/mcp`

Expand Down
2 changes: 1 addition & 1 deletion packages/ferrvault-api-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ferrvault/api-mcp",
"version": "10.3.0",
"description": "Model Context Protocol server for the FerrVault API: vaults, environments and secrets",
"description": "Model Context Protocol server for the FerrVault API: vaults, environments, secrets and service tokens",
"license": "MPL-2.0",
"type": "module",
"bin": {
Expand Down
8 changes: 8 additions & 0 deletions packages/ferrvault-api-mcp/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,11 @@ export function secretRequestsPath({ vault, environment }: SecretTarget): string
export function archiveSecretRequestPath(target: SecretTarget, id: string): string {
return `${secretRequestsPath(target)}/${encodeURIComponent(id)}/archive`;
}

export function serviceTokensPath({ vault, environment }: SecretTarget): string {
return `${vaultPath(vault)}/environments/${encodeURIComponent(environment)}/operator/tokens`;
}

export function serviceTokenPath(target: SecretTarget, id: string): string {
return `${serviceTokensPath(target)}/${encodeURIComponent(id)}`;
}
4 changes: 4 additions & 0 deletions packages/ferrvault-api-mcp/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { registerVaultTools } from './tools/vaults.js';
import { registerSecretReadTools } from './tools/secrets.js';
import { registerSecretWriteTools } from './tools/secret-writes.js';
import { registerSecretRequestTools } from './tools/secret-requests.js';
import { registerSecretCopyTools } from './tools/secret-copy.js';
import { registerServiceTokenTools } from './tools/service-tokens.js';

export function register(server: McpServer): void {
registerVaultTools(server);
registerSecretReadTools(server);
registerSecretWriteTools(server);
registerSecretRequestTools(server);
registerSecretCopyTools(server);
registerServiceTokenTools(server);
}
23 changes: 14 additions & 9 deletions packages/ferrvault-api-mcp/src/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,42 @@ export interface ToolResult {
isError?: true;
}

export type Redact = (secret: string) => void;

export function textResult(value: unknown): ToolResult {
return { content: [{ type: 'text', text: toToolText(value) }] };
}

function redact(message: string, secret: string | undefined): string {
return secret ? message.split(secret).join('[redacted]') : message;
function redact(message: string, secrets: readonly string[]): string {
return secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), message);
}

function describe(err: unknown, secret: string | undefined): string {
const message = redact(err instanceof Error ? err.message : String(err), secret);
export function describeError(err: unknown, secrets: readonly string[] = []): string {
const message = redact(err instanceof Error ? err.message : String(err), secrets);
if (err instanceof ApiRequestError) {
const code = err.code ? ` ${err.code}` : '';
return `FerrVault API error (HTTP ${err.status}${code}): ${message}`;
}
return message;
}

function errorResult(err: unknown, secret?: string): ToolResult {
function errorResult(err: unknown, secrets: readonly string[]): ToolResult {
return {
isError: true,
content: [{ type: 'text', text: describe(err, secret) }],
content: [{ type: 'text', text: describeError(err, secrets) }],
};
}

export async function guarded(
run: () => Promise<ToolResult>,
run: (redactLater: Redact) => Promise<ToolResult>,
secret?: string,
): Promise<ToolResult> {
const secrets = secret ? [secret] : [];
try {
return await run();
return await run((later) => {
if (later) secrets.push(later);
});
} catch (err) {
return errorResult(err, secret);
return errorResult(err, secrets);
}
}
18 changes: 18 additions & 0 deletions packages/ferrvault-api-mcp/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,21 @@ export const requestedName = z
.min(1)
.max(200)
.describe('Secret name a client requested, as listed by list_ferrvault_secret_requests');

export const serviceTokenId = z
.uuid()
.describe('Service token id, as listed by list_ferrvault_service_tokens');

export const serviceTokenName = z
.string()
.min(1)
.max(100)
.describe('Label shown for the token in FerrVault (e.g. k8s-operator-prod)');

export const vaultRole = z
.enum(['viewer', 'writer', 'admin'])
.describe('Role the token acts with on its vault environment');

export const expiresAt = z.iso
.datetime({ offset: true })
.describe('ISO-8601 instant after which the token stops working; omit for no expiry');
167 changes: 167 additions & 0 deletions packages/ferrvault-api-mcp/src/tools/__tests__/copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import {
API,
accepts,
call,
metadata,
mockFetch,
respond,
sent,
useRegisteredTools,
} from './harness.js';

const SOURCE_SECRETS = `${API}/vaults/infra/environments/prod/secrets`;
const TARGET_SECRETS = `${API}/vaults/apps/environments/staging/secrets`;
const VALUE = 'pa$$w0rd-from-prod';

const source = { vault: 'infra', environment: 'prod', name: 'DB_PASSWORD' };
const target = { vault: 'apps', environment: 'staging', name: 'APP_DB_PASSWORD' };

function revealed(version = 7) {
return respond({ ...metadata, current_version: version, value: VALUE });
}

describe('copy_ferrvault_secret', () => {
useRegisteredTools();

it('reveals the source, then creates the target with the same value', async () => {
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce(respond(metadata));
const result = await call('copy_ferrvault_secret', { source, target });

expect(mockFetch).toHaveBeenCalledTimes(2);
expect(sent(0)).toMatchObject({ method: 'GET', url: `${SOURCE_SECRETS}/DB_PASSWORD` });
expect(sent(1)).toMatchObject({ method: 'POST', url: TARGET_SECRETS });
expect(sent(1).body).toEqual({ name: 'APP_DB_PASSWORD', value: VALUE });
expect(JSON.parse(result.content[0].text)).toEqual({
outcome: 'created',
target,
source_version: 7,
target_version: 1,
});
});

it('never returns the copied value', async () => {
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce(respond(metadata));
const result = await call('copy_ferrvault_secret', { source, target });

expect(result.isError).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(VALUE);
});

it('defaults the target name to the source name', async () => {
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce(respond(metadata));
const result = await call('copy_ferrvault_secret', {
source,
target: { vault: 'infra', environment: 'staging' },
});

expect(sent(1).url).toBe(`${API}/vaults/infra/environments/staging/secrets`);
expect(sent(1).body?.name).toBe('DB_PASSWORD');
expect(JSON.parse(result.content[0].text).target).toEqual({
vault: 'infra',
environment: 'staging',
name: 'DB_PASSWORD',
});
});

it('stores a new version when the target already exists', async () => {
mockFetch
.mockResolvedValueOnce(revealed())
.mockResolvedValueOnce(respond({ code: 'SECRET_EXISTS', error: 'exists' }, 409))
.mockResolvedValueOnce(respond({ ...metadata, current_version: 4 }));
const result = await call('copy_ferrvault_secret', { source, target });

expect(sent(2)).toMatchObject({ method: 'PUT', url: `${TARGET_SECRETS}/APP_DB_PASSWORD` });
expect(sent(2).body).toEqual({ value: VALUE });
expect(JSON.parse(result.content[0].text)).toMatchObject({
outcome: 'versioned',
source_version: 7,
target_version: 4,
});
});

it.each([
['an explicit name', { ...source }],
['the defaulted name', { vault: 'infra', environment: 'prod' }],
])('refuses a target equal to the source with %s', async (_label, to) => {
const result = await call('copy_ferrvault_secret', { source, target: to });

expect(mockFetch).not.toHaveBeenCalled();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('source and target are the same secret');
});

it('copies between environments under the same name', async () => {
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce(respond(metadata));
const result = await call('copy_ferrvault_secret', {
source,
target: { ...source, environment: 'dev' },
});

expect(result.isError).toBeUndefined();
expect(sent(1).url).toBe(`${API}/vaults/infra/environments/dev/secrets`);
});

it('redacts the value from a write error that echoes it', async () => {
mockFetch
.mockResolvedValueOnce(revealed())
.mockResolvedValueOnce(respond({ code: 'VALIDATION', error: `bad value ${VALUE}` }, 400));
const result = await call('copy_ferrvault_secret', { source, target });

expect(result.isError).toBe(true);
expect(JSON.stringify(result)).not.toContain(VALUE);
expect(result.content[0].text).toBe(
'FerrVault API error (HTTP 400 VALIDATION): bad value [redacted]',
);
});

it('redacts the value from a non-JSON error body', async () => {
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce({
ok: false,
status: 502,
text: () => Promise.resolve(`<html>upstream saw ${VALUE}</html>`),
} as unknown as Response);
const result = await call('copy_ferrvault_secret', { source, target });

expect(result.isError).toBe(true);
expect(JSON.stringify(result)).not.toContain(VALUE);
});

it('writes nothing when the source cannot be revealed', async () => {
mockFetch.mockResolvedValue(respond({ code: 'AUTH_FORBIDDEN', error: 'viewer role' }, 403));
const result = await call('copy_ferrvault_secret', { source, target });

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.content[0].text).toBe(
'FerrVault API error (HTTP 403 AUTH_FORBIDDEN): viewer role',
);
});

it('forwards the caller bearer and the version header on the read and the write', async () => {
const { runWithAuthContext } = await import('@ferrlabs/mcp-core');
mockFetch.mockResolvedValueOnce(revealed()).mockResolvedValueOnce(respond(metadata));
await runWithAuthContext({ bearerToken: 'caller-token' }, () =>
call('copy_ferrvault_secret', { source, target }),
);

for (const index of [0, 1]) {
expect(sent(index).headers['Authorization']).toBe('Bearer caller-token');
expect(sent(index).headers['x-ferrvault-api-version']).toBe('2026-08-04');
}
});

it.each([
['a traversing source vault', { source: { ...source, vault: '..' }, target }],
['a slash in the target name', { source, target: { ...target, name: 'a/b' } }],
['an uppercase target environment', { source, target: { ...target, environment: 'Prod' } }],
['a missing source name', { source: { vault: 'infra', environment: 'prod' }, target }],
])('rejects %s', (_label, params) => {
expect(accepts('copy_ferrvault_secret', params)).toBe(false);
});

it('accepts a target without a name', () => {
expect(
accepts('copy_ferrvault_secret', { source, target: { vault: 'apps', environment: 'dev' } }),
).toBe(true);
});
});
Loading
Loading