Skip to content
Open
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io

## Features

### Tools (13)
### Tools (15)

| Tool | Description |
|------|-------------|
Expand All @@ -29,6 +29,8 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io
| `password_update` | Rotate/update an existing password |
| `password_generate` | Generate a cryptographically secure random password |
| `password_generate_memorable` | Generate a memorable passphrase from ~500 dictionary words |
| `op_run` | Run a local command with `op://` references injected as env vars; all references for that command resolve in one bulk SDK request, and plaintext is redacted from stdout/stderr and never logged (the MCP equivalent of `op run`) |
| `op_check_ref` | Validate an `op://vault/item/field` reference and return only non-secret metadata (vault, item, field) confirming it resolves — never the value |

### Prompts (4)

Expand Down Expand Up @@ -121,13 +123,28 @@ Then set `OP_SERVICE_ACCOUNT_TOKEN` in your shell/session/CI environment.

On macOS, you can also omit `OP_SERVICE_ACCOUNT_TOKEN` and set `OP_KEYCHAIN_SERVICE` (plus optional `OP_KEYCHAIN_ACCOUNT`) to read the token from Keychain at startup.

#### Restricting `op_run` / `op_check_ref` to specific vaults (optional)

By default, `op_run` and `op_check_ref` may resolve `op://` references from any
vault the service account can access. To restrict them to an allow-list, set
`OP_MCP_ALLOWED_VAULTS` (or pass `--allowed-vaults`) to a comma-separated list of
vault names or IDs. References to any other vault are rejected before resolution.

```jsonc
"env": {
"OP_SERVICE_ACCOUNT_TOKEN": "YOUR_SERVICE_ACCOUNT_TOKEN",
"OP_MCP_ALLOWED_VAULTS": "Automation, CI"
}
```

### CLI Options

```
--service-account-token <token> 1Password service account token
--log-level <level> Log level: error, warn, info, debug (default: info)
--integration-name <name> Custom integration name for 1Password SDK
--integration-version <version> Custom integration version
--allowed-vaults <list> Comma-separated vault allow-list for op_run/op_check_ref (default: unrestricted)
```

---
Expand Down
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ export interface ServerConfig {
serviceAccountToken: string | undefined;
/** Where the token came from. */
tokenSource: "args" | "env" | "keychain" | "missing";
/**
* Vault names/IDs that `op_run`/`op_check_ref` may resolve `op://` references
* from. An empty list means no restriction (any accessible vault is allowed).
*/
allowedVaults: string[];
}

/**
* Parse a comma-separated vault allow-list. An unset/blank value yields an
* empty list, which the reference resolvers treat as "no restriction".
*/
export function parseAllowedVaults(raw: string | undefined): string[] {
if (!raw || !raw.trim()) return [];
return raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}

let _config: ServerConfig | undefined;
Expand Down Expand Up @@ -129,13 +146,18 @@ export function getConfig(): ServerConfig {
const { serviceAccountToken, tokenSource } =
resolveServiceAccountToken({ tokenFromArgs });

const allowedVaults = parseAllowedVaults(
getArgValue("allowed-vaults") ?? process.env.OP_MCP_ALLOWED_VAULTS,
);

_config = {
logLevel: logLevelRaw,
logLevelValue,
integrationName,
integrationVersion,
serviceAccountToken,
tokenSource,
allowedVaults,
};

return _config;
Expand Down
54 changes: 54 additions & 0 deletions src/secret-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Shared helpers for parsing and validating `op://vault/item/field` secret
* references, used by `op_run` and `op_check_ref`.
*/

import { getConfig } from "./config.js";

export interface ParsedSecretRef {
/** Raw reference string, e.g. "op://Private/github/token". */
raw: string;
/** Vault segment (name or ID) as written in the reference. */
vault: string;
/** Item segment (name or ID) as written in the reference. */
item: string;
/** Field segment (name or ID) as written in the reference. */
field: string;
}

const OP_REF_PATTERN = /^op:\/\/([^/]+)\/([^/]+)\/(.+)$/;

/** True if the given string looks like an `op://` secret reference. */
export function isSecretRef(value: string): boolean {
return OP_REF_PATTERN.test(value);
}

/** Parse an `op://vault/item/field` reference into its segments. */
export function parseSecretRef(raw: string): ParsedSecretRef {
const match = OP_REF_PATTERN.exec(raw);
if (!match) {
throw new Error(
`Invalid secret reference '${raw}'. Expected format: op://vault/item/field`,
);
}
const [, vault, item, field] = match;
return { raw, vault, item, field };
}

/**
* Throw if the reference's vault segment is not in the server's configured
* vault allow-list. An empty allow-list (the default) permits any vault.
* Comparison is case-insensitive against vault names/IDs.
*/
export function assertVaultAllowed(vault: string): void {
const { allowedVaults } = getConfig();
if (allowedVaults.length === 0) return; // no restriction configured
const normalized = vault.toLowerCase();
const allowed = allowedVaults.some((entry) => entry.toLowerCase() === normalized);
if (!allowed) {
throw new Error(
`Vault '${vault}' is not in the allowed vault list (${allowedVaults.join(", ")}). ` +
"Configure OP_MCP_ALLOWED_VAULTS or --allowed-vaults to permit it.",
);
}
}
4 changes: 4 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { registerPasswordRead } from "./password-read.js";
import { registerPasswordUpdate } from "./password-update.js";
import { registerPasswordGenerate } from "./password-generate.js";
import { registerPasswordGenerateMemorable } from "./password-generate-memorable.js";
import { registerOpRun } from "./op-run.js";
import { registerOpCheckRef } from "./op-check-ref.js";

/** Register every tool with the MCP server. */
export function registerAllTools(server: McpServer): void {
Expand All @@ -32,4 +34,6 @@ export function registerAllTools(server: McpServer): void {
registerPasswordUpdate(server);
registerPasswordGenerate(server);
registerPasswordGenerateMemorable(server);
registerOpRun(server);
registerOpCheckRef(server);
}
84 changes: 84 additions & 0 deletions src/tools/op-check-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* op_check_ref — Validate an op://vault/item/field secret reference and
* return metadata about it (vault, item, field label/type) WITHOUT ever
* returning the secret value. Use this to sanity-check a reference before
* passing it to op_run.
*/

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getClient } from "../client.js";
import { log, logError } from "../logger.js";
import { jsonResult, errorResult } from "../utils.js";
import { parseSecretRef, assertVaultAllowed } from "../secret-ref.js";

export function registerOpCheckRef(server: McpServer): void {
server.tool(
"op_check_ref",
"Validate an op://vault/item/field secret reference and return only non-secret metadata (vault name, item title, field label, field type) confirming it resolves — the field VALUE is never returned. Use this to check a reference is correct before using it with op_run; do not use password_read/item_get with reveal just to check a reference exists.",
{
secretReference: z
.string()
.min(1)
.describe("Secret reference to validate, in op://vault/item/field format."),
},
async ({ secretReference }) => {
try {
log("debug", "Tool call: op_check_ref.", { secretReference: true });

const ref = parseSecretRef(secretReference);
assertVaultAllowed(ref.vault);

const client = await getClient();
if (!client?.secrets?.resolveAll || !client?.items?.get) {
throw new Error(
"Your @1password/sdk version does not support resolving secret references.",
);
}

const resolved = await client.secrets.resolveAll([secretReference]);
const response = resolved.individualResponses[secretReference];
if (!response?.content) {
const reason = response?.error?.type ?? "unknown";
throw new Error(
`Could not resolve secret reference '${secretReference}' (${reason}).`,
);
}

const { vaultId, itemId } = response.content;
const item: any = await client.items.get(vaultId, itemId);

const desiredField = ref.field.toLowerCase();
const fields: any[] = item.fields ?? [];
const match = fields.find((candidate: any) => {
const idMatch = candidate.id?.toLowerCase() === desiredField;
const titleMatch = candidate.title?.toLowerCase() === desiredField;
const labelMatch = candidate.label?.toLowerCase() === desiredField;
return idMatch || titleMatch || labelMatch;
});

if (!match) {
throw new Error(
`Field '${ref.field}' not found on item '${item.title}'.`,
);
}

// Deliberately omit match.value — this tool never returns secret plaintext.
return jsonResult({
resolved: true,
vault: { id: vaultId, name: ref.vault },
item: { id: item.id, title: item.title, category: item.category },
field: {
id: match.id,
title: match.title,
type: match.fieldType ?? match.type,
section: match.sectionId,
},
});
} catch (error) {
logError("op_check_ref failed.", error);
return errorResult(error);
}
},
);
}
Loading
Loading