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
10 changes: 6 additions & 4 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@

- **pageId** (number) **(required)**: Targets a specific page by ID.
- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot
- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.
- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it.
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.

---
Expand Down Expand Up @@ -168,7 +168,7 @@
**Parameters:**

- **pageId** (number) **(required)**: Targets a specific page by ID.
- **text** (string) **(required)**: The text to type
- **text** (string) **(required)**: The text to type. May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it.
- **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape"

---
Expand Down Expand Up @@ -379,8 +379,10 @@
**Parameters:**

- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the target page.
Example without arguments: `() => document.title` or `async () => await fetch("example.com")`.
Example with arguments: `(el) => el.innerText`
Example without arguments: `() => document.title` or `async () => await fetch("example.com")`.
Example with arguments: `(el) => el.innerText`
May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it.
To run the same code repeatedly without writing it out in every call, store it in ~/.local/share/chrome-devtools-mcp/scripts/NAME and pass `{{script:NAME}}`, which this MCP server replaces with that file's contents exactly as stored. A script takes no modifiers: it is never deleted, and it is never trimmed.

- **pageId** (number) **(required)**: Targets a specific page by ID.
- **args** (array) _(optional)_: An optional list of arguments to pass to the function.
Expand Down
134 changes: 134 additions & 0 deletions skills/secret-handling/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
name: secret-handling
description: Uses Chrome DevTools MCP to fill passwords, API keys, tokens and other credentials into a page without the plaintext ever appearing in the conversation, and to reuse the same script across calls. Use when logging into a site, filling a password or 2FA field, automating an authenticated flow, or running the same JavaScript repeatedly.
---

## Core Concepts

### Why placeholders exist

Everything you pass to a tool is recorded in the conversation transcript. Typing a password directly into `fill`, `fill_form`, `type_text` or `evaluate_script` therefore writes that password into a durable log that anyone reading the transcript later can see.

Instead, name the credential and let the MCP server substitute it:

```json
{"uid": "5_3", "value": "{{secret:acme-login-7f3a}}"}
```

The server reads the file, splices the value in, and dispatches it to the browser. You never see the value, so it cannot leak through you.

> [!IMPORTANT]
> The rule is about **where the plaintext exists**, not about who types it. Staging a secret with `echo "hunter2" > .../secrets/x` puts the password in a tool call and defeats the entire mechanism. Always stage **by reference** — see below.

### Placeholder reference

The exact directories are named in the `value` / `text` / `function` parameter descriptions of the tools; read them rather than guessing a path.

| Placeholder | Trailing newline | File after the call |
| :---------------------- | :--------------- | :------------------ |
| `{{secret:X}}` | stripped | **deleted** |
| `{{secret:X:raw}}` | kept | **deleted** |
| `{{secret:X:keep}}` | stripped | kept |
| `{{secret:X:raw:keep}}` | kept | kept |
| `{{script:X}}` | kept (always) | kept (always) |

- Secrets are **consumed by default** so they do not linger on disk. Use `:keep` for a credential you need in several calls (e.g. a password plus a confirmation field in separate steps).
- Deletion happens only **after the call succeeds**, so a failed fill leaves the secret staged and you can retry without re-staging.
- `{{script:X}}` takes **no modifiers** — passing `:raw` or `:keep` is an error. A script is never deleted and never trimmed.

### Staging a secret by reference

Write the file from an existing source so the plaintext never passes through a tool argument:

```bash
pass show acme/login > <secrets-dir>/acme-login-7f3a # password manager
cp ~/.config/acme/token <secrets-dir>/acme-token-9c21 # existing file
printf '%s' "$ACME_PASSWORD" > <secrets-dir>/acme-login-7f3a # environment variable
security find-generic-password -s acme -w > <secrets-dir>/acme-login-7f3a # macOS keychain
```

Never `echo "<the actual password>"`. If the credential only exists in something the user typed into the chat, it has already been recorded and this mechanism cannot retroactively protect it — say so rather than pretending otherwise.

### Choose unique names

The secrets directory is shared by every MCP server running in parallel. Use a specific name (`acme-login-7f3a`), never a generic one (`pw`): another session can overwrite a generic name and make you fill the wrong credential into the wrong site, or delete it while you still need it.

---

## Workflow Patterns

### 1. Logging into a site

1. **Stage the credential** by reference (see above), choosing a unique name.
2. **Locate the fields**: `take_snapshot` to get the `uid`s of the username and password inputs.
3. **Fill both in one call** with `fill_form`, using the placeholder for the password:
```json
{
"elements": [
{"uid": "5_3", "value": "[email protected]"},
{"uid": "5_4", "value": "{{secret:acme-login-7f3a}}"}
]
}
```
4. **Submit**: `click` the submit button, or pass `submitKey: "Enter"` to `type_text`.
5. **Verify**: `take_snapshot` or `list_network_requests` to confirm the login succeeded.

The secret file is gone after step 3 succeeded. If the login failed and you need to retry, the file is still there only if the _fill_ failed — a fill that succeeded against a wrong-password page still consumes it, so stage with `:keep` when you expect to retry.

### 2. Two-factor codes

A TOTP code is short-lived, so generate it straight into the secrets directory and let it be consumed:

```bash
oathtool --totp -b "$(pass show acme/totp-seed)" > <secrets-dir>/acme-otp-4b8e
```

Then `fill` with `{{secret:acme-otp-4b8e}}`. Consume-by-default is exactly right here: the code is useless after one use and should not remain on disk.

### 3. Reusing a script across calls

To run the same JavaScript repeatedly without writing it out in every call, store the function declaration once and reference it:

```bash
cat > <scripts-dir>/collect-metrics.js <<'EOF'
() => ({
title: document.title,
forms: document.forms.length,
errors: [...document.querySelectorAll('.error')].map(e => e.textContent),
})
EOF
```

Then call `evaluate_script` with `{"function": "{{script:collect-metrics.js}}"}` as often as you like. The code is still sent to the browser each time; you are simply spared repeating it.

### 4. A script that needs a credential

Resolution runs in **two passes — scripts first, then secrets** — so a stored script may contain a secret placeholder and both are resolved in one call:

```js
// <scripts-dir>/login.js
async () => {
document.querySelector('#user').value = '[email protected]';
document.querySelector('#pass').value = '{{secret:acme-login-7f3a}}';
document.querySelector('form').submit();
};
```

Called as `{"function": "{{script:login.js}}"}`, the script is inserted and then its secret is resolved.

Substituted content is never rescanned, which means a script **cannot** reference another script, and a secret whose value happens to look like a placeholder is used literally.

---

## Troubleshooting

- **`No secret named "x" found at ...`**: The file was never staged, or a previous call consumed it. Re-stage it, and use `:keep` if several calls need it.
- **`Invalid secret name "..."`**: Names are plain file names. Paths, `..` and `/` are rejected so a reference cannot escape the directory.
- **`{{script:x}} takes no modifiers`**: Scripts are never consumed and never trimmed, so `:raw` and `:keep` are meaningless there. Drop the modifier.
- **`Unknown modifier ":..."`**: Only `:raw` and `:keep` exist, in any order.
- **The placeholder was typed into the page literally**: The parameter you used does not resolve placeholders. Only `fill`, `fill_form`, `type_text` and `evaluate_script`'s `function` do.
- **The wrong value was filled**: Another session probably reused the same generic name. Re-stage under a unique name.
- **You need to confirm what was filled**: Error messages and `type_text`'s confirmation deliberately echo the _placeholder_, never the substituted value. Verify the effect (a successful login, a snapshot) instead of trying to read the value back.

> [!WARNING]
> The substituted value is still sent to the site in the login request. After submitting credentials, avoid calling `get_network_request` on that request, or saving it with `requestFilePath`, unless you actually need it — the request body contains the plaintext and would put it back into the transcript.
23 changes: 1 addition & 22 deletions src/telemetry/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
*/

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

import {logger} from '../utils/logger.js';
import {getDataFolder} from '../utils/paths.js';

import {ClearcutLogger} from './ClearcutLogger.js';
import {ErrorCode} from './errors.js';
Expand All @@ -34,26 +33,6 @@ function isContextValid(state: LocalState): boolean {
}

const STATE_FILE_NAME = 'telemetry_state.json';
function getDataFolder(): string {
const homedir = os.homedir();
const {env} = process;
const name = 'chrome-devtools-mcp';

if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', name);
}

if (process.platform === 'win32') {
const localAppData =
env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
return path.join(localAppData, name, 'Data');
}

return path.join(
env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'),
name,
);
}

export interface Persistence {
loadState(): Promise<LocalState>;
Expand Down
40 changes: 31 additions & 9 deletions src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import type {ElementHandle, KeyInput} from '../third_party/index.js';
import type {TextSnapshotNode} from '../types.js';
import {parseKey} from '../utils/keyboard.js';
import {logger} from '../utils/logger.js';
import {
deleteSecrets,
placeholderHint,
resolvePlaceholders,
} from '../utils/secrets.js';
import type {WaitForEventsResult} from '../utils/WaitForHelper.js';

import {ToolCategory} from './categories.js';
Expand Down Expand Up @@ -211,6 +216,7 @@ async function selectOption(
handle: ElementHandle,
aXNode: TextSnapshotNode,
value: string,
displayValue: string,
) {
let optionFound = false;
for (const child of aXNode.children) {
Expand All @@ -230,7 +236,7 @@ async function selectOption(
}
}
if (!optionFound) {
throw new Error(`Could not find option with text "${value}"`);
throw new Error(`Could not find option with text "${displayValue}"`);
}
}

Expand All @@ -243,14 +249,17 @@ async function fillFormElement(
value: string,
context: McpContext,
page: ContextPage,
// Used in error messages in place of `value`, so that a value resolved from
// a secret is never echoed back to the caller.
displayValue: string = value,
) {
using handle = await page.getElementByUid(uid);
try {
const aXNode = page.getAXNodeByUid(uid);
// We assume that combobox needs to be handled as select if it has
// role='combobox' and option children.
if (aXNode && aXNode.role === 'combobox' && hasOptionChildren(aXNode)) {
await selectOption(handle, aXNode, value);
await selectOption(handle, aXNode, value, displayValue);
} else {
const isToggle = await handle.evaluate(el => {
if (el instanceof HTMLInputElement) {
Expand All @@ -265,7 +274,7 @@ async function fillFormElement(
await handle.asLocator().fill(value === 'true');
} else {
throw new Error(
`Checkboxes, radio boxes and toggles require "true" or "false" value, but ${value} was used`,
`Checkboxes, radio boxes and toggles require "true" or "false" value, but ${displayValue} was used`,
);
}
} else {
Expand Down Expand Up @@ -297,22 +306,25 @@ export const fill = definePageTool({
value: zod
.string()
.describe(
'The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.',
`The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. ${placeholderHint}`,
),
includeSnapshot: includeSnapshotSchema,
},
blockedByDialog: true,
verifyFilesSchema: {},
handler: async (request, response, context) => {
const page = request.page;
const secret = await resolvePlaceholders(request.params.value);
const result = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
request.params.uid,
request.params.value,
secret.value,
context as McpContext,
page,
request.params.value,
);
});
await deleteSecrets(secret.consume);
response.appendResponseLine(`Successfully filled out the element`);
response.attachWaitForResult(result);
if (request.params.includeSnapshot) {
Expand All @@ -329,21 +341,24 @@ export const typeText = definePageTool({
readOnlyHint: false,
},
schema: {
text: zod.string().describe('The text to type'),
text: zod.string().describe(`The text to type. ${placeholderHint}`),
submitKey: submitKeySchema,
},
blockedByDialog: true,
verifyFilesSchema: {},
handler: async (request, response) => {
const page = request.page;
const secret = await resolvePlaceholders(request.params.text);
const result = await page.waitForEventsAfterAction(async () => {
await page.pptrPage.keyboard.type(request.params.text);
await page.pptrPage.keyboard.type(secret.value);
if (request.params.submitKey) {
await page.pptrPage.keyboard.press(
request.params.submitKey as KeyInput,
);
}
});
await deleteSecrets(secret.consume);
// Echoes the unresolved text so a resolved secret is never reported back.
response.appendResponseLine(
`Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`,
);
Expand Down Expand Up @@ -400,7 +415,7 @@ export const fillForm = definePageTool({
value: zod
.string()
.describe(
'Value for the element. "true" or "false" for checkboxes and toggles, "true" for radio buttons.',
`Value for the element. "true" or "false" for checkboxes and toggles, "true" for radio buttons. ${placeholderHint}`,
),
}),
)
Expand All @@ -412,16 +427,23 @@ export const fillForm = definePageTool({
handler: async (request, response, context) => {
const page = request.page;
let lastResult: WaitForEventsResult = {};
const usedSecrets = new Set<string>();
for (const element of request.params.elements) {
const secret = await resolvePlaceholders(element.value);
for (const name of secret.consume) {
usedSecrets.add(name);
}
lastResult = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
element.uid,
element.value,
secret.value,
context as McpContext,
page,
element.value,
);
});
}
await deleteSecrets([...usedSecrets]);
response.appendResponseLine(`Successfully filled out the form`);
response.attachWaitForResult(lastResult);
if (request.params.includeSnapshot) {
Expand Down
Loading
Loading