Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .github/workflows/server-lint-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ===============================================================
# 🧩 Server Lint & Test - BFF Quality Gate
# ===============================================================
# - runs typecheck and Vitest for the BFF (server/)
# - runs on PRs and pushes to main
# ---------------------------------------------------------------

name: Server Lint & Test

on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, ready_for_review]
branches: ["main"]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
lint-and-test:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
name: Lint & Test Server
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: server

steps:
- name: ⬇️ Checkout source
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
fetch-depth: 1

- name: 🟩 Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"

- name: 📥 Install dependencies
run: npm ci --no-audit --no-fund

- name: 🔍 Typecheck
run: npm run lint

- name: 🧪 Run tests with Vitest
run: npm run test:run
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ server/node_modules/
# Build outputs
dist/
server/dist/
server/public/.vite/
server/public/assets/
# Entire SPA build dir — vite's outDir with emptyOutDir:true regenerates all
# of it, including index.html; nothing under here should be tracked.
server/public/

# Test outputs
test-results/
Expand Down
89 changes: 75 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,90 @@ npm install

### Development

The client development workflow requires both the client dev server and the backend gateway:
The app is split into three pieces that all must run for local dev:

1. **Build the client assets:**
- **ContextForge** (`mcpgateway`) — the upstream FastAPI gateway. It owns
auth and all business data.
- **BFF** (`server/`) — a Fastify app that sits between the browser and
ContextForge. It holds the session cookie/CSRF boundary and keeps the
API's JWT off the browser (`server/src/index.ts`). The browser only ever
talks to the BFF, never directly to ContextForge.
- **Client** (`src/`) — this React SPA, served as static files by the BFF
(same-origin — the API client always calls relative paths, see
`src/api/client.ts`).

Bring them up in this order:

1. **Start ContextForge** — the upstream `mcp-context-forge` repo. Follow
its own quick-start guide:
https://github.com/IBM/mcp-context-forge/issues/2503
Note whatever port it ends up listening on for the next step.

2. **Configure and start the BFF** (terminal B, this repo's `server/`):

```bash
npm run build
cd server
cp .env.example .env
```

2. **Start the client development server:**
Edit `server/.env`:
- `FASTAPI_URL` — point it at whatever host:port ContextForge is
listening on from step 1 (`.env.example`'s default is `4444`; confirm
against your ContextForge run rather than assuming).
- `COOKIE_SECURE=false` — needed for local HTTP; the default (`true`) is
for prod and silently drops the session cookie over plain HTTP.

Other values (`PORT`, `REDIS_URL`, `SESSION_TTL_SECONDS`, etc.) have
dev-safe defaults — see comments in `server/.env.example`.
`REDIS_URL=memory://` (the default) is an in-process store, no Redis
process needed for local dev — state resets on restart.

```bash
npm run dev
npm install
npm run dev # :3000, tsx watch
```

This starts the Vite dev server at `http://localhost:5173` with hot module replacement.

3. **In another terminal, start the backend gateway:**
3. **Build the frontend for the BFF to serve**, from the repo root:

```bash
make dev
npm install
npm run build
```

4. **Access the application:**
Open your browser and navigate to `http://localhost:8000/app` to view the UI.
This builds the SPA into `server/public/`, which the already-running BFF
serves directly. Re-run `npm run build` after any frontend change —
there's no HMR dev server wired to the BFF, so this build step is the
loop for local iteration against the real backend. (`npm run build:watch`
reruns it automatically on file changes.)

4. **Use it.** Visit `http://localhost:3000/` — redirects to `/app/login`
(unauthed) or `/app/` (authed). The login form posts through the BFF,
which holds the ContextForge JWT server-side and hands the browser only
an opaque session cookie.

Default seeded admin: `admin@example.com` / `changeme` (first login
forces a password change unless `PASSWORD_CHANGE_ENFORCEMENT_ENABLED=false`
is set in ContextForge's `.env`).

> `npm run dev` (plain Vite dev server at `:5173`, no BFF in front) still
> works for UI-only iteration, but `/api/*` calls need the BFF — it won't
> reach ContextForge on its own.

#### Troubleshooting

- **`EADDRINUSE` on `:3000`** — stale `tsx watch` process:
`lsof -ti:3000 | xargs kill`, then restart `npm run dev` in `server/`.
- **401 mid-session** — expected; the ContextForge token hard-expires per
`TOKEN_EXPIRY` (default 20 min). The BFF auto-revokes the session and
redirects to login.

### Build

```bash
npm run build
```

Builds the production bundle to `dist/`.
Builds the SPA into `server/public/`, for the BFF to serve.

### Preview Production Build

Expand Down Expand Up @@ -272,8 +324,17 @@ client/
├── vitest.config.ts # Vitest configuration
├── tsconfig.json # TypeScript base config
├── tsconfig.app.json # TypeScript app config
├── vite.config.ts # Vite configuration
└── package.json # Dependencies and scripts
├── vite.config.ts # Vite configuration (builds to server/public/)
├── package.json # Dependencies and scripts
└── server/ # BFF (Fastify): session/CSRF boundary in front of ContextForge
├── src/
│ ├── index.ts # Entrypoint
│ ├── config.ts # Env-driven config
│ ├── plugins/ # cookie, redis, session, csrf, static
│ └── routes/ # auth/, proxy/ (catch-all to ContextForge), sse/
├── public/ # Built SPA (npm run build output), served by BFF
├── .env.example # Copy to .env and configure FASTAPI_URL etc.
└── package.json
```

## Available Scripts
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ContextForge</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"dev:e2e": "vite --base=/ --port 5173 --strictPort",
"build": "npm run generate && tsc -b && vite build",
"build:watch": "vite build --watch",
"build:bff": "npm run generate && tsc -b && vite build --config vite.bff.config.ts",
"preview": "vite preview",
"lint": "eslint src e2e",
"lint:fix": "eslint src e2e --fix",
Expand Down
Binary file added public/favicon.ico
Binary file not shown.
51 changes: 51 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# BFF server config. Copy to .env and adjust for your environment.

PORT=3000
HOST=0.0.0.0

# Upstream ContextForge API (FastAPI). Server-to-server only.
FASTAPI_URL=http://127.0.0.1:4444

# Must match mcpgateway's own AUTH_HEADER_NAME.
FASTAPI_AUTH_HEADER_NAME=Authorization

# memory:// = in-process store, no Redis process needed (dev only — state is
# lost on restart, not shared across instances). Use a real redis:// URL for
# anything beyond a single local dev process, e.g. redis://localhost:6379/0.
REDIS_URL=memory://

# Opaque session_id -> bearer token TTL in Redis, seconds.
SESSION_TTL_SECONDS=86400

# Redis key namespace. Only needs changing if multiple BFF deployments
# (e.g. staging and prod) ever share one Redis instance.
REDIS_KEY_PREFIX=bff

# Leave unset for a host-only cookie (recommended unless the BFF and its
# subdomains genuinely need to share the session cookie).
COOKIE_DOMAIN=
# "false" is the local-HTTP dev value, and is what this file ships with so a
# fresh `cp .env.example .env` boots against the REDIS_URL=memory:// default
# above. Set to "true" in prod — config.ts fails closed on COOKIE_SECURE=true
# paired with either memory:// or an unset PUBLIC_ORIGIN/TRUST_PROXY, so a prod
# deployment must set REDIS_URL and PUBLIC_ORIGIN (or TRUST_PROXY) alongside it.
COOKIE_SECURE=false

# Only safe behind a trusted reverse proxy that overwrites (not appends to)
# X-Forwarded-For. Leave "false" for a directly-exposed BFF.
TRUST_PROXY=false

# Exact scheme://host the BFF is publicly reached at (e.g.
# https://app.example.com), used for Origin-header validation on login/SSE.
# Leave unset to derive it from the request itself — fine for a
# single-hostname deployment; set explicitly behind a reverse proxy where
# that derivation isn't trustworthy (e.g. TLS-terminated without
# TRUST_PROXY=true).
PUBLIC_ORIGIN=

# How often an open SSE connection re-checks Redis for session revocation,
# as a fallback to the pub/sub-based instant revocation. See
# agent-output/bff-proxy-and-sse-plan.md.
SSE_SESSION_RECHECK_SECONDS=15

LOG_LEVEL=info
Loading
Loading