From 7a9d0f995ed536e80896cd1686b30a9ed8bb3e98 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Wed, 7 Oct 2026 10:12:57 +0200 Subject: [PATCH 1/2] feat(vault): add secret copy and service token tools to @ferrvault/api-mcp --- README.md | 26 +- packages/ferrvault-api-mcp/package.json | 2 +- packages/ferrvault-api-mcp/src/api.ts | 8 + packages/ferrvault-api-mcp/src/register.ts | 4 + packages/ferrvault-api-mcp/src/results.ts | 23 +- packages/ferrvault-api-mcp/src/schemas.ts | 18 ++ .../src/tools/__tests__/copy.test.ts | 167 ++++++++++++ .../src/tools/__tests__/harness.ts | 91 +++++++ .../tools/__tests__/service-tokens.test.ts | 252 ++++++++++++++++++ .../src/tools/__tests__/tools.test.ts | 80 +----- .../src/tools/secret-copy.ts | 54 ++++ .../src/tools/secret-writes.ts | 51 +--- .../src/tools/service-tokens.ts | 141 ++++++++++ packages/ferrvault-api-mcp/src/upsert.ts | 40 +++ scripts/smoke.ts | 4 + 15 files changed, 823 insertions(+), 138 deletions(-) create mode 100644 packages/ferrvault-api-mcp/src/tools/__tests__/copy.test.ts create mode 100644 packages/ferrvault-api-mcp/src/tools/__tests__/harness.ts create mode 100644 packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts create mode 100644 packages/ferrvault-api-mcp/src/tools/secret-copy.ts create mode 100644 packages/ferrvault-api-mcp/src/tools/service-tokens.ts create mode 100644 packages/ferrvault-api-mcp/src/upsert.ts diff --git a/README.md b/README.md index 5a4a4f7..7be61d9 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/packages/ferrvault-api-mcp/package.json b/packages/ferrvault-api-mcp/package.json index 40685e8..f060732 100644 --- a/packages/ferrvault-api-mcp/package.json +++ b/packages/ferrvault-api-mcp/package.json @@ -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": { diff --git a/packages/ferrvault-api-mcp/src/api.ts b/packages/ferrvault-api-mcp/src/api.ts index a5c139a..b8931df 100644 --- a/packages/ferrvault-api-mcp/src/api.ts +++ b/packages/ferrvault-api-mcp/src/api.ts @@ -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)}`; +} diff --git a/packages/ferrvault-api-mcp/src/register.ts b/packages/ferrvault-api-mcp/src/register.ts index 9bbba00..4bc307b 100644 --- a/packages/ferrvault-api-mcp/src/register.ts +++ b/packages/ferrvault-api-mcp/src/register.ts @@ -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); } diff --git a/packages/ferrvault-api-mcp/src/results.ts b/packages/ferrvault-api-mcp/src/results.ts index 3fba33e..b2687e5 100644 --- a/packages/ferrvault-api-mcp/src/results.ts +++ b/packages/ferrvault-api-mcp/src/results.ts @@ -6,16 +6,18 @@ 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}`; @@ -23,20 +25,23 @@ function describe(err: unknown, secret: string | undefined): string { 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, + run: (redactLater: Redact) => Promise, secret?: string, ): Promise { + 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); } } diff --git a/packages/ferrvault-api-mcp/src/schemas.ts b/packages/ferrvault-api-mcp/src/schemas.ts index 50ad8e6..8ba25bc 100644 --- a/packages/ferrvault-api-mcp/src/schemas.ts +++ b/packages/ferrvault-api-mcp/src/schemas.ts @@ -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'); diff --git a/packages/ferrvault-api-mcp/src/tools/__tests__/copy.test.ts b/packages/ferrvault-api-mcp/src/tools/__tests__/copy.test.ts new file mode 100644 index 0000000..521e799 --- /dev/null +++ b/packages/ferrvault-api-mcp/src/tools/__tests__/copy.test.ts @@ -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(`upstream saw ${VALUE}`), + } 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); + }); +}); diff --git a/packages/ferrvault-api-mcp/src/tools/__tests__/harness.ts b/packages/ferrvault-api-mcp/src/tools/__tests__/harness.ts new file mode 100644 index 0000000..7a86423 --- /dev/null +++ b/packages/ferrvault-api-mcp/src/tools/__tests__/harness.ts @@ -0,0 +1,91 @@ +import { vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { z } from 'zod'; +import type { McpServer } from '@ferrlabs/mcp-core'; + +export type Result = { content: { text: string }[]; isError?: boolean }; +type Handler = (params: Record) => Promise; + +const handlers = new Map(); +const shapes = new Map(); +const server = { + tool: (name: string, _description: string, shape: z.ZodRawShape, handler: Handler) => { + handlers.set(name, handler); + shapes.set(name, shape); + }, +} as unknown as McpServer; + +export const mockFetch: Mock = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +export const API = 'https://api.ferrvault.ferrlabs'; + +export const metadata = { + id: 's1', + vault_id: 'v1', + environment_id: 'e1', + name: 'DB_PASSWORD', + current_version: 1, + tags: [], + expires_at: null, + expiry_action: 'notify', + expired: false, + created_at: '2026-10-07T00:00:00Z', + updated_at: '2026-10-07T00:00:00Z', +}; + +export function respond(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body === undefined ? '' : JSON.stringify(body)), + } as unknown as Response; +} + +interface Sent { + url: string; + method: string; + headers: Record; + body: Record | undefined; +} + +export function sent(index = 0): Sent { + const [url, init] = mockFetch.mock.calls[index]; + return { + url: String(url), + method: init.method, + headers: init.headers, + body: init.body ? JSON.parse(init.body) : undefined, + }; +} + +export function call(name: string, params: Record = {}): Promise { + const handler = handlers.get(name); + if (!handler) throw new Error(`tool ${name} is not registered`); + return handler(params); +} + +export function accepts(name: string, params: Record): boolean { + const shape = shapes.get(name); + if (!shape) throw new Error(`tool ${name} is not registered`); + return z.object(shape).safeParse(params).success; +} + +export function useRegisteredTools(): void { + beforeEach(async () => { + vi.resetModules(); + mockFetch.mockReset(); + handlers.clear(); + shapes.clear(); + process.env.FERRLABS_API_TOKEN = 'jwt-from-idp'; + process.env.FERRVAULT_API_URL = `${API}/`; + process.env.FERRLABS_MCP_ALLOWED_API_HOSTS = 'api.ferrvault.ferrlabs'; + const { register } = await import('../../register.js'); + register(server); + }); + + afterEach(() => { + delete process.env.FERRLABS_API_TOKEN; + delete process.env.FERRVAULT_API_URL; + delete process.env.FERRLABS_MCP_ALLOWED_API_HOSTS; + }); +} diff --git a/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts b/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts new file mode 100644 index 0000000..fb3c832 --- /dev/null +++ b/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect } from 'vitest'; +import { + API, + accepts, + call, + metadata, + mockFetch, + respond, + sent, + useRegisteredTools, +} from './harness.js'; + +const TOKENS = `${API}/vaults/infra/environments/prod/operator/tokens`; +const STORE_SECRETS = `${API}/vaults/infra/environments/ci/secrets`; +const TOKEN_ID = '0b0e3c4e-6c2f-4f0e-9a51-7a1f2d1c9e01'; +const SAT = 'fvsat_Zm9vYmFyYmF6cXV4c2VjcmV0dG9rZW5ib2R5MTIzNDU2'; + +const row = { + id: TOKEN_ID, + label: 'k8s-operator-prod', + role: 'viewer', + token_preview: 'Zm9vYmFy', + created_by: 'u1', + created_at: '2026-10-07T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + last_used_at: null, +}; + +const tokenMetadata = { + id: TOKEN_ID, + name: 'k8s-operator-prod', + vault: 'infra', + environment: 'prod', + role: 'viewer', + created_by: 'u1', + created_at: '2026-10-07T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + last_used_at: null, +}; + +const store = { vault: 'infra', environment: 'ci', name: 'OPERATOR_TOKEN' }; + +const create = { + vault: 'infra', + environment: 'prod', + name: 'k8s-operator-prod', + role: 'viewer', + expires_at: '2027-01-01T00:00:00Z', + store, +}; + +describe('list_ferrvault_service_tokens', () => { + useRegisteredTools(); + + it('lists metadata from the environment operator token route', async () => { + mockFetch.mockResolvedValue(respond([row])); + const result = await call('list_ferrvault_service_tokens', { + vault: 'infra', + environment: 'prod', + }); + + expect(sent()).toMatchObject({ method: 'GET', url: TOKENS }); + expect(JSON.parse(result.content[0].text)).toEqual([tokenMetadata]); + }); + + it('drops the token, its hash and its preview even if the API sends them', async () => { + mockFetch.mockResolvedValue( + respond([{ ...row, token: SAT, token_hash: '$argon2id$v=19$hash', allowed_cidrs: [] }]), + ); + const result = await call('list_ferrvault_service_tokens', { + vault: 'infra', + environment: 'prod', + }); + + const text = JSON.stringify(result); + expect(text).not.toContain(SAT); + expect(text).not.toContain('argon2id'); + expect(text).not.toContain(row.token_preview); + }); +}); + +describe('create_ferrvault_service_token', () => { + useRegisteredTools(); + + it('creates the token, then writes its value into the store secret', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond({ ...metadata, name: 'OPERATOR_TOKEN' })); + const result = await call('create_ferrvault_service_token', create); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(sent(0)).toMatchObject({ method: 'POST', url: TOKENS }); + expect(sent(0).body).toEqual({ + label: 'k8s-operator-prod', + role: 'viewer', + expires_at: '2027-01-01T00:00:00Z', + }); + expect(sent(1)).toMatchObject({ method: 'POST', url: STORE_SECRETS }); + expect(sent(1).body).toEqual({ name: 'OPERATOR_TOKEN', value: SAT }); + expect(JSON.parse(result.content[0].text)).toEqual({ + token: tokenMetadata, + stored: { outcome: 'created', ...store, version: 1 }, + }); + }); + + it('never returns the token value', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond(metadata)); + const result = await call('create_ferrvault_service_token', create); + + expect(result.isError).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(SAT); + }); + + it('omits the expiry when none is given', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, expires_at: null, token: SAT })) + .mockResolvedValueOnce(respond(metadata)); + const { expires_at: _omitted, ...withoutExpiry } = create; + await call('create_ferrvault_service_token', withoutExpiry); + + expect(sent(0).body).toEqual({ label: 'k8s-operator-prod', role: 'viewer' }); + }); + + it('stores a new version when the store secret exists', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond({ code: 'SECRET_EXISTS', error: 'exists' }, 409)) + .mockResolvedValueOnce(respond({ ...metadata, current_version: 5 })); + const result = await call('create_ferrvault_service_token', create); + + expect(sent(2)).toMatchObject({ method: 'PUT', url: `${STORE_SECRETS}/OPERATOR_TOKEN` }); + expect(sent(2).body).toEqual({ value: SAT }); + expect(JSON.parse(result.content[0].text).stored).toEqual({ + outcome: 'versioned', + ...store, + version: 5, + }); + }); + + it('revokes the new token when storing it fails, without leaking it', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond({ code: 'VALIDATION', error: `rejected ${SAT}` }, 400)) + .mockResolvedValueOnce(respond(undefined, 204)); + const result = await call('create_ferrvault_service_token', create); + + expect(sent(2)).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${TOKEN_ID}` }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).not.toContain(SAT); + expect(result.content[0].text).toBe( + `storing service token ${TOKEN_ID} failed (FerrVault API error (HTTP 400 VALIDATION): rejected [redacted]); the token was revoked`, + ); + }); + + it('says so when the compensating revoke fails too', async () => { + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond({ code: 'AUTH_FORBIDDEN', error: 'viewer role' }, 403)) + .mockResolvedValueOnce(respond({ code: 'INTERNAL', error: 'boom' }, 500)); + const result = await call('create_ferrvault_service_token', create); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('revoking it failed too, revoke it by id'); + }); + + it('stores nothing when the API refuses to create the token', async () => { + mockFetch.mockResolvedValue(respond({ code: 'AUTH_FORBIDDEN', error: 'admin only' }, 403)); + const result = await call('create_ferrvault_service_token', create); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.content[0].text).toBe( + 'FerrVault API error (HTTP 403 AUTH_FORBIDDEN): admin only', + ); + }); + + it('forwards the caller bearer and the version header on every call', async () => { + const { runWithAuthContext } = await import('@ferrlabs/mcp-core'); + mockFetch + .mockResolvedValueOnce(respond({ ...row, token: SAT })) + .mockResolvedValueOnce(respond({ code: 'VALIDATION', error: 'no' }, 400)) + .mockResolvedValueOnce(respond(undefined, 204)); + await runWithAuthContext({ bearerToken: 'caller-token' }, () => + call('create_ferrvault_service_token', create), + ); + + expect(mockFetch).toHaveBeenCalledTimes(3); + for (const index of [0, 1, 2]) { + expect(sent(index).headers['Authorization']).toBe('Bearer caller-token'); + expect(sent(index).headers['x-ferrvault-api-version']).toBe('2026-08-04'); + } + }); + + it.each([ + ['an unknown role', { ...create, role: 'owner' }], + ['a non ISO expiry', { ...create, expires_at: 'next week' }], + ['an empty name', { ...create, name: '' }], + ['a name over 100 characters', { ...create, name: 'x'.repeat(101) }], + ['a traversing store vault', { ...create, store: { ...store, vault: '..' } }], + ['a store without a name', { ...create, store: { vault: 'infra', environment: 'ci' } }], + ])('rejects %s', (_label, params) => { + expect(accepts('create_ferrvault_service_token', params)).toBe(false); + }); + + it('accepts a request without expiry', () => { + const { expires_at: _omitted, ...withoutExpiry } = create; + expect(accepts('create_ferrvault_service_token', withoutExpiry)).toBe(true); + }); +}); + +describe('revoke_ferrvault_service_token', () => { + useRegisteredTools(); + + it('revokes with DELETE on the token route', async () => { + mockFetch.mockResolvedValue(respond(undefined, 204)); + const result = await call('revoke_ferrvault_service_token', { + vault: 'infra', + environment: 'prod', + id: TOKEN_ID, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(sent()).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${TOKEN_ID}` }); + expect(sent().body).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toEqual({ + revoked: TOKEN_ID, + vault: 'infra', + environment: 'prod', + }); + }); + + it('maps an unknown token to a tool error', async () => { + mockFetch.mockResolvedValue(respond({ code: 'TOKEN_NOT_FOUND', error: 'no such token' }, 404)); + const result = await call('revoke_ferrvault_service_token', { + vault: 'infra', + environment: 'prod', + id: TOKEN_ID, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + 'FerrVault API error (HTTP 404 TOKEN_NOT_FOUND): no such token', + ); + }); + + it.each(['..', `${TOKEN_ID}/..`, 'not-a-uuid'])('rejects %j as a token id', (id) => { + expect( + accepts('revoke_ferrvault_service_token', { vault: 'infra', environment: 'prod', id }), + ).toBe(false); + }); +}); diff --git a/packages/ferrvault-api-mcp/src/tools/__tests__/tools.test.ts b/packages/ferrvault-api-mcp/src/tools/__tests__/tools.test.ts index 5974ee3..89fb212 100644 --- a/packages/ferrvault-api-mcp/src/tools/__tests__/tools.test.ts +++ b/packages/ferrvault-api-mcp/src/tools/__tests__/tools.test.ts @@ -1,67 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { McpServer } from '@ferrlabs/mcp-core'; +import { describe, it, expect } from 'vitest'; +import { API, call, metadata, mockFetch, respond, sent, useRegisteredTools } from './harness.js'; -type Result = { content: { text: string }[]; isError?: boolean }; -type Handler = (params: Record) => Promise; - -const handlers = new Map(); -const server = { - tool: (name: string, _description: string, _schema: unknown, handler: Handler) => { - handlers.set(name, handler); - }, -} as unknown as McpServer; - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -const API = 'https://api.ferrvault.ferrlabs'; const SECRETS = `${API}/vaults/infra/environments/prod/secrets`; -const metadata = { - id: 's1', - vault_id: 'v1', - environment_id: 'e1', - name: 'DB_PASSWORD', - current_version: 1, - tags: [], - expires_at: null, - expiry_action: 'notify', - expired: false, - created_at: '2026-10-07T00:00:00Z', - updated_at: '2026-10-07T00:00:00Z', -}; - -function respond(body: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - text: () => Promise.resolve(body === undefined ? '' : JSON.stringify(body)), - } as unknown as Response; -} - -interface Sent { - url: string; - method: string; - headers: Record; - body: Record | undefined; -} - -function sent(index = 0): Sent { - const [url, init] = mockFetch.mock.calls[index]; - return { - url: String(url), - method: init.method, - headers: init.headers, - body: init.body ? JSON.parse(init.body) : undefined, - }; -} - -function call(name: string, params: Record = {}): Promise { - const handler = handlers.get(name); - if (!handler) throw new Error(`tool ${name} is not registered`); - return handler(params); -} - const target = { vault: 'infra', environment: 'prod', name: 'DB_PASSWORD' }; const REQUESTS = `${API}/vaults/infra/environments/prod/secret-requests`; @@ -85,22 +26,7 @@ function secretRequest(id: string, name: string, state: string) { } describe('ferrvault api tools', () => { - beforeEach(async () => { - vi.resetModules(); - mockFetch.mockReset(); - handlers.clear(); - process.env.FERRLABS_API_TOKEN = 'jwt-from-idp'; - process.env.FERRVAULT_API_URL = `${API}/`; - process.env.FERRLABS_MCP_ALLOWED_API_HOSTS = 'api.ferrvault.ferrlabs'; - const { register } = await import('../../register.js'); - register(server); - }); - - afterEach(() => { - delete process.env.FERRLABS_API_TOKEN; - delete process.env.FERRVAULT_API_URL; - delete process.env.FERRLABS_MCP_ALLOWED_API_HOSTS; - }); + useRegisteredTools(); it('sends the bearer token and pins the contract version on every call', async () => { mockFetch.mockResolvedValue(respond([])); diff --git a/packages/ferrvault-api-mcp/src/tools/secret-copy.ts b/packages/ferrvault-api-mcp/src/tools/secret-copy.ts new file mode 100644 index 0000000..626d9de --- /dev/null +++ b/packages/ferrvault-api-mcp/src/tools/secret-copy.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { getToken, type McpServer } from '@ferrlabs/mcp-core'; +import { secretPath, vaultRequest } from '../api.js'; +import { guarded, textResult } from '../results.js'; +import { environmentSlug, secretName, vaultSlug } from '../schemas.js'; +import type { RevealedSecret } from '../secret.js'; +import { upsertSecret, versionOutcome } from '../upsert.js'; + +const source = z + .object({ vault: vaultSlug, environment: environmentSlug, name: secretName }) + .describe('Secret to copy from'); + +const target = z + .object({ vault: vaultSlug, environment: environmentSlug, name: secretName.optional() }) + .describe('Where to store the copy; name defaults to the source name'); + +type SecretRef = z.infer; + +function sameSecret(a: SecretRef, b: SecretRef): boolean { + return a.vault === b.vault && a.environment === b.environment && a.name === b.name; +} + +export function registerSecretCopyTools(server: McpServer): void { + server.tool( + 'copy_ferrvault_secret', + 'Copy the current value of a secret to another vault, environment or name, creating the target or storing a new version of it. The value is read and written inside the MCP server and never returned, so it never enters the conversation. The read is audit-logged by FerrVault.', + { source, target }, + ({ source: from, target: to }) => + guarded(async (redactLater) => { + const dest = { ...to, name: to.name ?? from.name }; + if (sameSecret(from, dest)) { + throw new Error('source and target are the same secret'); + } + const token = await getToken(); + const revealed = await vaultRequest( + secretPath({ vault: from.vault, environment: from.environment }, from.name), + { token }, + ); + redactLater(revealed.value); + const written = await upsertSecret( + token, + { vault: dest.vault, environment: dest.environment }, + dest.name, + revealed.value, + ); + return textResult({ + outcome: versionOutcome(written), + target: dest, + source_version: revealed.current_version, + target_version: written.secret.current_version, + }); + }), + ); +} diff --git a/packages/ferrvault-api-mcp/src/tools/secret-writes.ts b/packages/ferrvault-api-mcp/src/tools/secret-writes.ts index f30b262..732eabd 100644 --- a/packages/ferrvault-api-mcp/src/tools/secret-writes.ts +++ b/packages/ferrvault-api-mcp/src/tools/secret-writes.ts @@ -1,48 +1,19 @@ import { z } from 'zod'; -import { ApiRequestError, getToken, type McpServer } from '@ferrlabs/mcp-core'; -import { type SecretTarget, secretPath, secretsPath, vaultRequest } from '../api.js'; +import { getToken, type McpServer } from '@ferrlabs/mcp-core'; +import type { SecretTarget } from '../api.js'; import { CHARSETS, type Charset, generateValue } from '../generate.js'; import { guarded, textResult } from '../results.js'; import { environmentSlug, secretName, vaultSlug } from '../schemas.js'; -import { type SecretMetadata, withoutValue } from '../secret.js'; +import { withoutValue } from '../secret.js'; +import { type Upserted, upsertSecret } from '../upsert.js'; -type Outcome = 'created' | 'rotated'; - -interface Written { - outcome: Outcome; - secret: SecretMetadata; -} - -function isAlreadyThere(err: unknown): boolean { - return err instanceof ApiRequestError && err.status === 409 && err.code === 'SECRET_EXISTS'; -} - -async function upsertSecret( - token: string, - target: SecretTarget, - name: string, - value: string, -): Promise { - try { - const secret = await vaultRequest(secretsPath(target), { - token, - method: 'POST', - body: { name, value }, - }); - return { outcome: 'created', secret }; - } catch (err) { - if (!isAlreadyThere(err)) throw err; - } - const secret = await vaultRequest(secretPath(target, name), { - token, - method: 'PUT', - body: { value }, - }); - return { outcome: 'rotated', secret }; -} - -function summary({ outcome, secret }: Written, target: SecretTarget, extra: object = {}) { - return { outcome, ...target, ...extra, secret: withoutValue(secret) }; +function summary({ created, secret }: Upserted, target: SecretTarget, extra: object = {}) { + return { + outcome: created ? 'created' : 'rotated', + ...target, + ...extra, + secret: withoutValue(secret), + }; } const charsetNames = Object.keys(CHARSETS) as [Charset, ...Charset[]]; diff --git a/packages/ferrvault-api-mcp/src/tools/service-tokens.ts b/packages/ferrvault-api-mcp/src/tools/service-tokens.ts new file mode 100644 index 0000000..884ea57 --- /dev/null +++ b/packages/ferrvault-api-mcp/src/tools/service-tokens.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; +import { getToken, type McpServer } from '@ferrlabs/mcp-core'; +import { type SecretTarget, serviceTokenPath, serviceTokensPath, vaultRequest } from '../api.js'; +import { describeError, guarded, textResult } from '../results.js'; +import { + environmentSlug, + expiresAt, + secretName, + serviceTokenId, + serviceTokenName, + vaultRole, + vaultSlug, +} from '../schemas.js'; +import { type Upserted, upsertSecret, versionOutcome } from '../upsert.js'; + +interface ServiceTokenRow { + id: string; + label: string; + role: z.infer; + created_by: string; + created_at: string; + expires_at: string | null; + last_used_at: string | null; +} + +interface CreatedServiceToken extends ServiceTokenRow { + token: string; +} + +function metadata(row: ServiceTokenRow, scope: SecretTarget) { + return { + id: row.id, + name: row.label, + vault: scope.vault, + environment: scope.environment, + role: row.role, + created_by: row.created_by, + created_at: row.created_at, + expires_at: row.expires_at, + last_used_at: row.last_used_at, + }; +} + +async function revokeQuietly(token: string, scope: SecretTarget, id: string): Promise { + try { + await vaultRequest(serviceTokenPath(scope, id), { token, method: 'DELETE' }); + return true; + } catch { + return false; + } +} + +async function storeOrRevoke( + token: string, + scope: SecretTarget, + created: CreatedServiceToken, + into: SecretTarget & { name: string }, +): Promise { + try { + return await upsertSecret( + token, + { vault: into.vault, environment: into.environment }, + into.name, + created.token, + ); + } catch (err) { + const revoked = await revokeQuietly(token, scope, created.id); + throw new Error( + `storing service token ${created.id} failed (${describeError(err)}); ${ + revoked ? 'the token was revoked' : 'revoking it failed too, revoke it by id' + }`, + ); + } +} + +const store = z + .object({ vault: vaultSlug, environment: environmentSlug, name: secretName }) + .describe('Secret the new token value is written to, created or versioned'); + +export function registerServiceTokenTools(server: McpServer): void { + server.tool( + 'list_ferrvault_service_tokens', + 'List the active service tokens (operator tokens) bound to one vault environment: id, name, role, creator, creation, expiry and last use. Never includes the token value. Requires the admin role on the vault.', + { vault: vaultSlug, environment: environmentSlug }, + ({ vault, environment }) => + guarded(async () => { + const token = await getToken(); + const scope = { vault, environment }; + const rows = await vaultRequest(serviceTokensPath(scope), { token }); + return textResult(rows.map((row) => metadata(row, scope))); + }), + ); + + server.tool( + 'create_ferrvault_service_token', + 'Create a service token (operator token) bound to one vault environment with a role, and write its value into a secret (created, or versioned when it exists) inside the MCP server. The token value is never returned, so it never enters the conversation. If the write fails, the new token is revoked. Requires the admin role on the vault, and write access to the store secret.', + { + vault: vaultSlug, + environment: environmentSlug, + name: serviceTokenName, + role: vaultRole, + expires_at: expiresAt.optional(), + store, + }, + ({ vault, environment, name, role, expires_at, store: into }) => + guarded(async (redactLater) => { + const token = await getToken(); + const scope = { vault, environment }; + const created = await vaultRequest(serviceTokensPath(scope), { + token, + method: 'POST', + body: { label: name, role, expires_at }, + }); + redactLater(created.token); + const written = await storeOrRevoke(token, scope, created, into); + return textResult({ + token: metadata(created, scope), + stored: { + outcome: versionOutcome(written), + ...into, + version: written.secret.current_version, + }, + }); + }), + ); + + server.tool( + 'revoke_ferrvault_service_token', + 'Revoke a service token of one vault environment by id. Workloads using it lose access immediately. Requires the admin role on the vault.', + { vault: vaultSlug, environment: environmentSlug, id: serviceTokenId }, + ({ vault, environment, id }) => + guarded(async () => { + const token = await getToken(); + await vaultRequest(serviceTokenPath({ vault, environment }, id), { + token, + method: 'DELETE', + }); + return textResult({ revoked: id, vault, environment }); + }), + ); +} diff --git a/packages/ferrvault-api-mcp/src/upsert.ts b/packages/ferrvault-api-mcp/src/upsert.ts new file mode 100644 index 0000000..fab3c32 --- /dev/null +++ b/packages/ferrvault-api-mcp/src/upsert.ts @@ -0,0 +1,40 @@ +import { ApiRequestError } from '@ferrlabs/mcp-core'; +import { type SecretTarget, secretPath, secretsPath, vaultRequest } from './api.js'; +import type { SecretMetadata } from './secret.js'; + +export interface Upserted { + created: boolean; + secret: SecretMetadata; +} + +function isAlreadyThere(err: unknown): boolean { + return err instanceof ApiRequestError && err.status === 409 && err.code === 'SECRET_EXISTS'; +} + +export async function upsertSecret( + token: string, + target: SecretTarget, + name: string, + value: string, +): Promise { + try { + const secret = await vaultRequest(secretsPath(target), { + token, + method: 'POST', + body: { name, value }, + }); + return { created: true, secret }; + } catch (err) { + if (!isAlreadyThere(err)) throw err; + } + const secret = await vaultRequest(secretPath(target, name), { + token, + method: 'PUT', + body: { value }, + }); + return { created: false, secret }; +} + +export function versionOutcome({ created }: Upserted): 'created' | 'versioned' { + return created ? 'created' : 'versioned'; +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 0d928ba..4c2f4be 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -79,6 +79,10 @@ const SERVERS: ServerUnderTest[] = [ 'delete_ferrvault_secret', 'list_ferrvault_secret_requests', 'archive_ferrvault_secret_request', + 'copy_ferrvault_secret', + 'list_ferrvault_service_tokens', + 'create_ferrvault_service_token', + 'revoke_ferrvault_service_token', ], }, { From 8761b691b0d63d70954cdb761e1fa082d146f714 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Wed, 7 Oct 2026 10:14:43 +0200 Subject: [PATCH 2/2] refactor(vault): share the upsert outcome label and rename the token id fixture --- .../tools/__tests__/service-tokens.test.ts | 20 +++++++++---------- .../src/tools/secret-copy.ts | 4 ++-- .../src/tools/secret-writes.ts | 8 ++++---- .../src/tools/service-tokens.ts | 4 ++-- packages/ferrvault-api-mcp/src/upsert.ts | 7 +++++-- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts b/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts index fb3c832..f63360d 100644 --- a/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts +++ b/packages/ferrvault-api-mcp/src/tools/__tests__/service-tokens.test.ts @@ -12,11 +12,11 @@ import { const TOKENS = `${API}/vaults/infra/environments/prod/operator/tokens`; const STORE_SECRETS = `${API}/vaults/infra/environments/ci/secrets`; -const TOKEN_ID = '0b0e3c4e-6c2f-4f0e-9a51-7a1f2d1c9e01'; +const SAT_ID = '0b0e3c4e-6c2f-4f0e-9a51-7a1f2d1c9e01'; const SAT = 'fvsat_Zm9vYmFyYmF6cXV4c2VjcmV0dG9rZW5ib2R5MTIzNDU2'; const row = { - id: TOKEN_ID, + id: SAT_ID, label: 'k8s-operator-prod', role: 'viewer', token_preview: 'Zm9vYmFy', @@ -27,7 +27,7 @@ const row = { }; const tokenMetadata = { - id: TOKEN_ID, + id: SAT_ID, name: 'k8s-operator-prod', vault: 'infra', environment: 'prod', @@ -146,11 +146,11 @@ describe('create_ferrvault_service_token', () => { .mockResolvedValueOnce(respond(undefined, 204)); const result = await call('create_ferrvault_service_token', create); - expect(sent(2)).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${TOKEN_ID}` }); + expect(sent(2)).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${SAT_ID}` }); expect(result.isError).toBe(true); expect(JSON.stringify(result)).not.toContain(SAT); expect(result.content[0].text).toBe( - `storing service token ${TOKEN_ID} failed (FerrVault API error (HTTP 400 VALIDATION): rejected [redacted]); the token was revoked`, + `storing service token ${SAT_ID} failed (FerrVault API error (HTTP 400 VALIDATION): rejected [redacted]); the token was revoked`, ); }); @@ -217,14 +217,14 @@ describe('revoke_ferrvault_service_token', () => { const result = await call('revoke_ferrvault_service_token', { vault: 'infra', environment: 'prod', - id: TOKEN_ID, + id: SAT_ID, }); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(sent()).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${TOKEN_ID}` }); + expect(sent()).toMatchObject({ method: 'DELETE', url: `${TOKENS}/${SAT_ID}` }); expect(sent().body).toBeUndefined(); expect(JSON.parse(result.content[0].text)).toEqual({ - revoked: TOKEN_ID, + revoked: SAT_ID, vault: 'infra', environment: 'prod', }); @@ -235,7 +235,7 @@ describe('revoke_ferrvault_service_token', () => { const result = await call('revoke_ferrvault_service_token', { vault: 'infra', environment: 'prod', - id: TOKEN_ID, + id: SAT_ID, }); expect(result.isError).toBe(true); @@ -244,7 +244,7 @@ describe('revoke_ferrvault_service_token', () => { ); }); - it.each(['..', `${TOKEN_ID}/..`, 'not-a-uuid'])('rejects %j as a token id', (id) => { + it.each(['..', `${SAT_ID}/..`, 'not-a-uuid'])('rejects %j as a token id', (id) => { expect( accepts('revoke_ferrvault_service_token', { vault: 'infra', environment: 'prod', id }), ).toBe(false); diff --git a/packages/ferrvault-api-mcp/src/tools/secret-copy.ts b/packages/ferrvault-api-mcp/src/tools/secret-copy.ts index 626d9de..83e3cab 100644 --- a/packages/ferrvault-api-mcp/src/tools/secret-copy.ts +++ b/packages/ferrvault-api-mcp/src/tools/secret-copy.ts @@ -4,7 +4,7 @@ import { secretPath, vaultRequest } from '../api.js'; import { guarded, textResult } from '../results.js'; import { environmentSlug, secretName, vaultSlug } from '../schemas.js'; import type { RevealedSecret } from '../secret.js'; -import { upsertSecret, versionOutcome } from '../upsert.js'; +import { upsertSecret, outcome } from '../upsert.js'; const source = z .object({ vault: vaultSlug, environment: environmentSlug, name: secretName }) @@ -44,7 +44,7 @@ export function registerSecretCopyTools(server: McpServer): void { revealed.value, ); return textResult({ - outcome: versionOutcome(written), + outcome: outcome(written, 'versioned'), target: dest, source_version: revealed.current_version, target_version: written.secret.current_version, diff --git a/packages/ferrvault-api-mcp/src/tools/secret-writes.ts b/packages/ferrvault-api-mcp/src/tools/secret-writes.ts index 732eabd..284eeaf 100644 --- a/packages/ferrvault-api-mcp/src/tools/secret-writes.ts +++ b/packages/ferrvault-api-mcp/src/tools/secret-writes.ts @@ -5,14 +5,14 @@ import { CHARSETS, type Charset, generateValue } from '../generate.js'; import { guarded, textResult } from '../results.js'; import { environmentSlug, secretName, vaultSlug } from '../schemas.js'; import { withoutValue } from '../secret.js'; -import { type Upserted, upsertSecret } from '../upsert.js'; +import { type Upserted, outcome, upsertSecret } from '../upsert.js'; -function summary({ created, secret }: Upserted, target: SecretTarget, extra: object = {}) { +function summary(written: Upserted, target: SecretTarget, extra: object = {}) { return { - outcome: created ? 'created' : 'rotated', + outcome: outcome(written, 'rotated'), ...target, ...extra, - secret: withoutValue(secret), + secret: withoutValue(written.secret), }; } diff --git a/packages/ferrvault-api-mcp/src/tools/service-tokens.ts b/packages/ferrvault-api-mcp/src/tools/service-tokens.ts index 884ea57..abbab73 100644 --- a/packages/ferrvault-api-mcp/src/tools/service-tokens.ts +++ b/packages/ferrvault-api-mcp/src/tools/service-tokens.ts @@ -11,7 +11,7 @@ import { vaultRole, vaultSlug, } from '../schemas.js'; -import { type Upserted, upsertSecret, versionOutcome } from '../upsert.js'; +import { type Upserted, upsertSecret, outcome } from '../upsert.js'; interface ServiceTokenRow { id: string; @@ -116,7 +116,7 @@ export function registerServiceTokenTools(server: McpServer): void { return textResult({ token: metadata(created, scope), stored: { - outcome: versionOutcome(written), + outcome: outcome(written, 'versioned'), ...into, version: written.secret.current_version, }, diff --git a/packages/ferrvault-api-mcp/src/upsert.ts b/packages/ferrvault-api-mcp/src/upsert.ts index fab3c32..acbea8a 100644 --- a/packages/ferrvault-api-mcp/src/upsert.ts +++ b/packages/ferrvault-api-mcp/src/upsert.ts @@ -35,6 +35,9 @@ export async function upsertSecret( return { created: false, secret }; } -export function versionOutcome({ created }: Upserted): 'created' | 'versioned' { - return created ? 'created' : 'versioned'; +export function outcome( + { created }: Upserted, + existing: Existing, +): 'created' | Existing { + return created ? 'created' : existing; }