Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ jobs:
- run: npm test
- run: npm run build

mcp-proxy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp-proxy
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: "22"
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build

publish:
name: Publish
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ counts as PII".
| `data_classification: 'pii'` fields | pseudonym (single-line) / `[REDACTED]` (multiline) |
| Everything else | unchanged |

## MCP proxy (draft)

[`mcp-proxy/`](./mcp-proxy) is an MCP gateway that runs upstream MCP tool results (first preset:
Fullstory MCP) through this package before they reach an LLM. See its README.

## Development

```sh
Expand Down
14 changes: 14 additions & 0 deletions mcp-proxy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Fullstory API key (Admin/Architect). Region picked from prefix (eu1./na1.). Keep in a secret store.
FULLSTORY_API_KEY=eu1.xxxxx
# One token per client/user, comma-separated. Clients send `Authorization: Bearer <token>` or use /mcp/<token>.
PROXY_TOKENS=
# HMAC secret for deterministic pseudonyms. Unset -> static [REDACTED] (fail safe).
ANONYMIZATION_SECRET=
# Optional
# KEEP_USER_PROPERTIES=plan,role,org_id
# BLOCKED_TOOLS=session_screenshot,session_get_a11y_tree,session_diff
# ALLOWED_TOOLS=
# ALLOW_BINARY_CONTENT=false
# UPSTREAM_URL=https://api.eu1.fullstory.com/mcp/fullstory
# PORT=8080
# MCP_PATH=/mcp
3 changes: 3 additions & 0 deletions mcp-proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.env
17 changes: 17 additions & 0 deletions mcp-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=8080
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 8080
CMD ["node", "dist/index.js"]
69 changes: 69 additions & 0 deletions mcp-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# @epilot/anonymizing-mcp-proxy (draft)

MCP gateway that sits between an AI client (Claude, …) and an upstream MCP server and runs every
tool result through [`@epilot/anonymization`](../README.md) before the model sees it.
First preset: **Fullstory MCP**, whose tool results include end-user `user_properties`
(names, emails, uids, custom vars) and Fullstory has no per-scope access level to turn that off.

```
Claude ──(proxy token)──▶ anonymizing-mcp-proxy ──(FS API key)──▶ api.eu1.fullstory.com/mcp/fullstory
◀── pseudonymized ── ◀── raw ─────────
```

## What it does

| Layer | Behavior |
| --- | --- |
| **Credentials** | Holds the Fullstory API key ([documented gateway mode](https://developer.fullstory.com/mcp/authentication/)); clients authenticate with their own revocable `PROXY_TOKENS`. Client headers (cookies, auth) never reach Fullstory. |
| **User property bags** | Any object under `user_properties` / `userVars` / `properties` / `traits` / … is anonymized **strictly**: known PII keys pseudonymized (`displayName` → `person_4f2a…`, `email` → `…@anonymized.invalid`, `uid` → `value_…`), every other string **redacted** unless allow-listed via `KEEP_USER_PROPERTIES` (e.g. `plan,role`). |
| **Everything else** | `anonymizeUnknown` field heuristics + Fullstory overrides (`displayName`, `uid`, `ip`, `latlong`, …) + pattern scrub of emails / phones / IBANs in all strings. |
| **URLs** | Query-string values & fragments redacted (`?search=REDACTED`), except links into `*.fullstory.com` (replay links stay clickable). |
| **Prose / markdown output** | `Key: value` lines get the same field heuristics; rest is pattern-scrubbed. |
| **Binary content** | Images / audio / blobs replaced with a placeholder (pixels can't be anonymized). |
| **Blocked tools** | `session_screenshot`, `session_get_a11y_tree`, `session_diff` hidden from `tools/list` and answered locally with `isError` — raw DOM/pixels. Override with `BLOCKED_TOOLS` / `ALLOWED_TOOLS`. |
| **Transport** | Streamable HTTP: JSON and SSE responses (event-by-event, ids preserved), `Mcp-Session-Id` passthrough. Unknown content types → 502, never passthrough. |
| **Logging** | Method, tool name, status, latency. Never bodies. |

Pseudonyms are deterministic (HMAC, `ANONYMIZATION_SECRET`), so the same user maps to the same
pseudonym across tool calls — the model can still correlate sessions, count distinct users, etc.

## Run

```sh
npm ci && npm run build
FULLSTORY_API_KEY=eu1.… PROXY_TOKENS=$(openssl rand -hex 24) ANONYMIZATION_SECRET=$(openssl rand -hex 32) npm start
```

See [`.env.example`](.env.example) for all options. Docker: `docker build -t fs-mcp-proxy . && docker run -p 8080:8080 --env-file .env fs-mcp-proxy`.

### Claude Code

```sh
claude mcp add --transport http fullstory https://<proxy-host>/mcp --header "Authorization: Bearer <proxy-token>"
```

### claude.ai (org connector)

claude.ai custom connectors don't take static headers: use the path-token form
`https://<proxy-host>/mcp/<proxy-token>` (treat the URL as a secret), or add OAuth in front
(see follow-ups).

## Limitations — read this

- **Heuristic, not a guarantee.** Novel property names outside a user-property bag, or names in
free text (`"Max clicked Save"`), are not caught. Fullstory's own masking/exclusion rules stay the
first line of defense — keep them tight; this proxy is the second.
- **Pseudonyms are one-way.** If the model asks Fullstory to filter by `person_4f2a…`, Fullstory
won't find it. Filtering by raw PII was the thing we wanted to avoid anyway.
- **Upstream output format isn't contractually stable.** Tests use fixtures; validate against real
`get_session_events` / `get_opportunity` output before rollout and extend the overrides.
- **Shared API key** = everyone behind the proxy has the key's Fullstory permissions. Use per-user
proxy tokens so access can be revoked individually.

## Follow-ups before prod

1. Validate against live Fullstory MCP output (EU org), extend `src/fullstory.ts`.
2. Deploy (Lambda Function URL with response streaming, or ECS) with secrets from SSM.
3. OAuth 2.1 front door (e.g. epilot SSO / Cognito) instead of static tokens, for claude.ai.
4. Optional: request-side scrubbing of tool arguments.
5. Extract into its own repo (`epilot-dev/anonymizing-mcp-proxy`) — this folder is self-contained.
Loading
Loading