Skip to main content
Glama
README.md
# selfMCP

A personal [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. It exposes a
profile — bio, skills, projects, work experience — as structured tools that any MCP-compatible AI
client (Claude, ChatGPT, Codex) can query directly, secured with OAuth 2.1 and Dynamic Client
Registration (DCR, RFC 7591) so clients can connect without any manually-issued API key.

## Stack

- **Node.js + TypeScript + Express**
- **SQLite via `node:sqlite`** (Node's built-in driver, stable as of Node 22.5+) — no native
  addon compilation required, so `npm install` works out of the box on any machine, including one
  without build tools installed
- **[`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk)** for
  the MCP protocol itself (Streamable HTTP transport) and for the full OAuth 2.1 authorization
  server implementation (`mcpAuthRouter`), including Dynamic Client Registration
- **`jose`** for signing/verifying access tokens (stateless JWTs)
- **EJS** for the landing page and OAuth consent screen — no frontend build step

## Project layout

```
src/
  config/env.ts        Environment variable loading + validation (zod)
  db/                   SQLite schema, seed script, and typed repository functions
  mcp/
    server.ts           Builds an McpServer and registers tools
    tools/               get_bio, list_skills, list_projects, search_projects, list_experience
    router.ts            Mounts the Streamable HTTP transport at POST /mcp
  oauth/
    store.ts             SQLite-backed OAuthRegisteredClientsStore (handles DCR persistence)
    provider.ts           Implements the SDK's OAuthServerProvider interface
    tokens.ts              JWT access tokens + hashed, rotating refresh tokens
    consent.ts             Signs/verifies the short-lived consent-screen round trip
  web/
    routes.ts             Landing page (GET /) and consent decision handler (POST /consent)
    views/                 landing.ejs, consent.ejs
  app.ts                 Wires everything into one Express app
  server.ts              Entry point
public/style.css        Landing page styling
```

## Getting started

```bash
npm install
cp .env.example .env
```

Generate a signing secret and put it in `.env` as `TOKEN_SIGNING_SECRET`:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

Edit `src/db/seed.ts` with your real name, bio, skills, projects, and experience, then run:

```bash
npm run seed
npm run dev
```

The server listens on `http://localhost:3000` by default. Visit it in a browser to see the landing
page, or run `npm run inspector` in another terminal to poke the MCP endpoint directly with the
official [MCP Inspector](https://github.com/modelcontextprotocol/inspector).

## Scripts

| Script              | Purpose                                          |
| ------------------- | ------------------------------------------------ |
| `npm run dev`       | Run with hot reload (`tsx watch`)                |
| `npm run build`     | Compile TypeScript to `dist/`                    |
| `npm start`         | Run the compiled build (`node dist/server.js`)   |
| `npm run seed`      | (Re-)populate the database from `src/db/seed.ts` |
| `npm run typecheck` | `tsc --noEmit`                                   |
| `npm run lint`      | ESLint                                           |
| `npm run format`    | Prettier (writes)                                |
| `npm run inspector` | Launch the MCP Inspector dev tool                |

## How the OAuth / DCR flow works

This server is both the MCP resource server and its own OAuth 2.1 authorization server, built on
the SDK's `mcpAuthRouter`:

1. **Discovery** — clients fetch `/.well-known/oauth-authorization-server` and
   `/.well-known/oauth-protected-resource/mcp` to learn where to register and authenticate.
2. **Registration (DCR)** — `POST /register` lets any client dynamically obtain a `client_id`
   (RFC 7591), no pre-registration or manual API key needed.
3. **Authorization** — `GET /authorize` renders a consent screen (`src/web/views/consent.ejs`).
   The pending request (client, redirect URI, PKCE challenge, scope, state) is carried in a
   short-lived signed JWT in a hidden form field rather than server-side session state, since
   there's nothing else to persist between the redirect and the user's click.
4. **Consent decision** — `POST /consent` issues a one-time authorization code (stored in SQLite,
   60s TTL by default) and redirects back to the client's `redirect_uri`.
5. **Token exchange** — `POST /token` exchanges the code (with PKCE verification handled by the
   SDK itself) for an **access token** (a signed, stateless JWT, 1 hour TTL by default) and a
   **refresh token** (opaque, hashed at rest, single-use with rotation — reusing a spent refresh
   token fails immediately).
6. **Calling the server** — `POST /mcp` requires `Authorization: Bearer <access_token>`, verified
   by `requireBearerAuth` against the same provider.

Trade-off worth knowing: because access tokens are stateless JWTs, they can't be individually
revoked before they expire — only refresh tokens are revocable. Given the short (1 hour) default
TTL, this is a standard and acceptable trade-off, not an oversight.

### Helmet gotchas for OAuth servers specifically

Helmet's secure-by-default headers ship configured for a typical web app, not an OAuth
authorization server, and two of its defaults will silently break real MCP/OAuth clients while
looking completely fine under `curl` (since `curl` doesn't enforce any browser-side policy):

- **CSP `form-action 'self'`** blocks the browser from following the redirect `/consent` issues
  after an Allow/Deny decision, since that redirect must go to whatever `redirect_uri` the
  connecting client registered — a different origin, by design. The POST completes correctly
  server-side (you'll see a `302` in your logs) but the browser silently refuses to follow it and
  reverts to the previous page. Fixed here by allowing `formAction: ["'self'", '*']`.
- **`Cross-Origin-Opener-Policy: same-origin`** severs the `window.opener` relationship the moment
  a popup/redirected window navigates to this origin, which breaks clients that track flow
  completion via `window.opener` or `postMessage`. Fixed here by disabling COOP entirely
  (`crossOriginOpenerPolicy: false`).

Both are set in `src/app.ts`. If you're deploying behind a proxy (Render, most PaaS hosts),
also see the `trust proxy` note there — without it, `express-rate-limit` can't safely read
`X-Forwarded-For` and throws on every rate-limited route.

## Testing / connecting an MCP client

All of the methods below point at the same URL — `<deployed-url>/mcp` (e.g.
`https://selfmcp.onrender.com/mcp`) — and each one auto-discovers the OAuth metadata and completes
DCR + the consent flow on first connection; no manual API key needed.

If the server has been idle, the first request can take 50+ seconds to wake up — see
[Render (free tier)](#render-free-tier) below. That's expected, not a failure.

### Fastest: no account needed (MCP Inspector)

The official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) can call every
tool directly, with no Claude subscription, ChatGPT plan, or OpenAI account required:

```bash
npx @modelcontextprotocol/inspector
```

In the UI: **Add Server** → transport **Streamable HTTP** → paste the `/mcp` URL → toggle the
server on → click **Allow** on the consent screen that opens → open the **Tools** tab and try
`get_bio` or `list_skills` directly.

### Claude Desktop / Claude Code

Add a remote connector via Settings → Connectors, or in `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "selfmcp": { "url": "https://your-deployed-url/mcp" }
  }
}
```

### ChatGPT

This one has a real, non-obvious prerequisite step — custom connectors are gated behind
"Developer mode", which is off by default:

1. Settings → Security and login → scroll to **Developer mode** → turn it on (labeled "elevated
   risk" — it just means you can add your own connectors, not a security downgrade for your account)
2. Go to the **Plugins** page → click the **+** next to the search box → **Add manually**
3. Server URL: the `/mcp` URL; Authentication: **OAuth** (default) → check the risk
   acknowledgment → **Create**
4. Click **Sign in**, then **Allow** on the consent screen

### Codex CLI

```bash
npm install -g @openai/codex
codex mcp add selfmcp --url https://your-deployed-url/mcp
```

This registers the server via DCR and opens a browser to the consent screen automatically — click
**Allow** and it's connected. (`codex mcp get selfmcp` confirms it afterward.)

### Once connected, try asking

- "What's this person's professional background?"
- "List their skills relevant to a backend engineering role."
- "Search their projects for anything related to TypeScript or MCP."

## Deployment

Any host that can run a persistent Node process works (Render, Railway, Fly.io, a small VM). Avoid
serverless/functions platforms — the Streamable HTTP transport and SSE want a long-lived
connection, and the SQLite file needs a persistent disk.

1. Set `BASE_URL` to your real HTTPS URL (OAuth requires HTTPS in production; only `localhost` is
   exempted).
2. Set `TOKEN_SIGNING_SECRET` to a real random secret (never reuse the `.env.example` placeholder).
3. Make sure the volume/disk backing `DATABASE_PATH` persists across restarts.
4. Run `npm run build && npm run seed && npm start`, or use the included `Dockerfile`.

### Render (free tier)

A `render.yaml` Blueprint is included: on Render, **New + → Blueprint**, connect this repo, and it
configures the service automatically (build command, start command, and a generated
`TOKEN_SIGNING_SECRET`).

The free plan has no persistent disk — its filesystem resets not just on every deploy but every
time the service wakes up from idle (it spins down after ~15 minutes of inactivity). Two
consequences of that, and how this project handles them:

- **Profile/skills/projects/experience data** would be lost on every wake-up. `render.yaml` works
  around this by running `npm run seed` as part of the start command (not just at build time), so
  this static data is restored every time the process boots.
- **OAuth client registrations and refresh tokens** are dynamic, so they can't be "reseeded" the
  same way — they're genuinely wiped on each wake-up. In practice this just means an MCP client
  that registered before a spin-down will get an `invalid_client` error on its next call and needs
  to redo Dynamic Client Registration, which is expected, spec-compliant behavior for a client
  talking to a server that doesn't guarantee permanent client storage — not a bug. If this matters
  for your use case, move to a paid Render plan with a persistent disk instead.

## Known dependency advisory

`npm audit` flags a moderate path-traversal issue in `@hono/node-server` (a transitive dependency
of `@modelcontextprotocol/sdk`'s static-file-serving helper). This project never uses that
static-serving code path — only the SDK's protocol/transport and auth modules — so it isn't
exploitable here. Revisit this once the SDK bumps its `@hono/node-server` dependency.