README
¶
Vire Portal
Web application and MCP server for the Vire investment platform. Hosts a landing page, serves the MCP tool endpoint at /mcp, and proxies all tool calls to vire-server.
The portal is a Go server that renders HTML templates with Alpine.js for interactivity and provides an MCP (Model Context Protocol) endpoint for Claude and other MCP clients. It proxies tool calls to vire-server, injecting X-Vire-* headers for user context. Served from a Docker container alongside vire-server.
Repository layout: This repo contains the portal server and MCP bridge code. The Docker images (
ghcr.io/bobmcallan/vire-portal:latestandghcr.io/bobmcallan/vire-mcp:latest) are published to GHCR. The portal runs alongsideghcr.io/bobmcallan/vire-server:latestin a Docker Compose stack.vire-mcpis a stdio-to-HTTP bridge launched by Claude Desktop — it connects to vire-portal's MCP endpoint via OAuth 2.1 and re-exposes tools over stdio. Portal infrastructure (Cloud Run deployment) is managed by vire-infra Terraform (infra/modules/portal/).
Tech Stack
- Go 1.25+ with standard
net/http(no framework) - Go
html/templatefor server-side rendering - Alpine.js (CDN) for client-side interactivity
- Chart.js v4 (CDN) for portfolio growth chart
- Stateless -- all user data managed by vire-server via REST API
- TOML configuration with priority: defaults < file < env (VIRE_ prefix) < CLI flags
- Port 8080 -- default port; Docker local dev overrides to 8881 via
docker/vire-portal.toml - 80s B&W aesthetic -- IBM Plex Mono, no border-radius, no box-shadow, monochrome only
- No Firebase Auth SDK -- OAuth is handled via direct HTTP redirects and gateway API calls
Routes
| Route | Handler | Auth | Description |
|---|---|---|---|
GET / |
PageHandler | No | Landing page (server-rendered HTML template) |
GET /dashboard |
DashboardHandler | No | Dashboard (portfolio management, holdings, capital performance, indicators, growth chart) |
GET /strategy |
StrategyHandler | No | Strategy page (portfolio strategy and plan editors) |
GET /cash |
CashHandler | No | Cash page (cash transactions ledger, paged table) |
GET /mcp-info |
MCPPageHandler | No | MCP info page (connection config, tools catalog) |
GET /docs |
PageHandler | No | Docs page (Navexa setup instructions) |
GET /static/* |
PageHandler | No | Static files (CSS, JS) |
GET /ws/status |
WSStatusHandler | No | WebSocket status indicators (ping/response health check) |
POST /mcp |
MCPHandler | No | MCP endpoint (Streamable HTTP transport, dynamic tools) |
GET /.well-known/oauth-authorization-server |
OAuthServer | No | OAuth 2.1 authorization server metadata |
GET /.well-known/oauth-protected-resource |
OAuthServer | No | OAuth 2.1 protected resource metadata |
POST /register |
OAuthServer | No | Dynamic Client Registration (RFC 7591) |
GET /authorize |
OAuthServer | No | OAuth authorization endpoint (PKCE S256) |
POST /token |
OAuthServer | No | Token exchange (authorization_code + refresh_token) |
GET /api/health |
HealthHandler | No | Health check ({"status":"ok"}) |
GET /api/server-health |
ServerHealthHandler | No | Proxied vire-server health check |
GET /api/version |
VersionHandler | No | Version info (JSON) |
POST /api/auth/login |
AuthHandler | No | Email/password login (forwards to vire-server) |
POST /api/auth/logout |
AuthHandler | No | Clears session cookie, redirects to / |
GET /api/auth/login/google |
AuthHandler | No | Proxies Google OAuth redirect from vire-server |
GET /api/auth/login/github |
AuthHandler | No | Proxies GitHub OAuth redirect from vire-server |
GET /auth/callback |
AuthHandler | No | OAuth callback (receives ?token=, sets session cookie) |
GET /profile |
ProfileHandler | No | Profile page (user info + Navexa API key management) |
POST /profile |
ProfileHandler | No | Save profile (requires session cookie) |
Prerequisites
- Go 1.25+
Development
# Build the server binary
go build ./cmd/vire-portal/
# Run the server (auto-discovers docker/vire-portal.toml)
go run ./cmd/vire-portal/
# Run with custom port
go run ./cmd/vire-portal/ -p 9090
# Run with custom config
go run ./cmd/vire-portal/ -c custom.toml
# Run unit tests
go test ./internal/... -timeout 120s
# Run all tests (unit + UI -- UI tests start Docker containers)
go test ./...
# Vet for issues
go vet ./...
# Verify auth endpoints on a running server
./scripts/verify-auth.sh
Testing
The test environment is fully self-contained -- no sibling repositories or local builds of vire-server are needed. UI tests use testcontainers-go to spin up a 3-container stack (SurrealDB, vire-server from GHCR, vire-portal) automatically.
Docker test stack (tests/docker/docker-compose.yml):
- SurrealDB -- in-memory database for vire-server
- vire-server -- pulled from
ghcr.io/bobmcallan/vire-server:latest(no local build) - vire-portal -- built from the local repo
Run the full stack manually with compose:
docker compose -f tests/docker/docker-compose.yml up -d
# Portal available at http://localhost:8881
docker compose -f tests/docker/docker-compose.yml down
UI test runner (scripts/ui-test.sh):
# Run all UI test suites
./scripts/ui-test.sh all
# Run individual suites
./scripts/ui-test.sh smoke
./scripts/ui-test.sh dashboard
./scripts/ui-test.sh nav
./scripts/ui-test.sh auth
Results (logs and screenshots) are written to tests/logs/{timestamp}/.
CI (.github/workflows/test.yml): Runs automatically on PR and push to main. Unit tests run first (go vet + go test ./internal/...), then UI tests with headless Chrome.
Browser Tests
UI tests use chromedp (headless Chrome) and are configured via tests/ui/test_config.toml:
[results]
dir = "tests/logs" # Results directory (timestamped subdirs)
[server]
url = "http://localhost:8881" # Server under test
[browser]
headless = true # Set false to see browser
timeout_seconds = 30
Test categories:
- Smoke tests (
TestSmoke*): Landing page, login buttons, branding, dashboard loads - Dashboard tests (
TestDashboard*): Sections, panels, portfolio UI, capital performance, indicators, growth chart, refresh button, design rules - Strategy tests (
TestStrategy*): Strategy/plan editors, nav active state, Alpine init - Capital tests (
TestCapital*): Cash transactions table, pagination, summary row, transaction colors - Nav tests (
TestNav*): Hamburger menu, dropdown, mobile nav - Auth tests (
TestAuth*): OAuth redirect flows
Results include logs and screenshots in tests/logs/{timestamp}/.
The server runs on http://localhost:8080 by default (Docker local dev overrides to 8881 via docker/vire-portal.toml).
Dev Mode
Set environment = "dev" in the TOML config or VIRE_ENV=dev as an environment variable to enable dev mode. This adds:
- JWT signature verification is relaxed when
auth.jwt_secretis empty (legacy fallback extractssubclaim without signature check) POST /api/shutdownendpoint for graceful shutdown via HTTP- All other functionality remains identical to prod
Dev mode is disabled by default (environment = "prod").
# Run in dev mode
VIRE_ENV=dev go run ./cmd/vire-portal/
# Or set in config file
# environment = "dev"
Logging
Logging uses arbor with a fluent API. By default, logs are written to both console and file (logs/vire-portal.log). Configure via the [logging] section in the TOML config:
[logging]
level = "info"
format = "text"
outputs = ["console", "file"]
file_path = "logs/vire-portal.log"
max_size_mb = 10
max_backups = 5
Configuration
Configuration priority (highest wins): CLI flags > environment variables > TOML file > defaults.
| Setting | TOML Key | Environment Variable | CLI Flag | Default |
|---|---|---|---|---|
| Server port | server.port |
VIRE_SERVER_PORT |
-port, -p |
8080 |
| Server host | server.host |
VIRE_SERVER_HOST |
-host |
localhost |
| API URL | api.url |
VIRE_API_URL |
-- | http://localhost:8080 |
| JWT secret | auth.jwt_secret |
VIRE_AUTH_JWT_SECRET |
-- | "" |
| OAuth callback URL | auth.callback_url |
VIRE_AUTH_CALLBACK_URL |
-- | http://localhost:8080/auth/callback |
| Portal URL | auth.portal_url |
VIRE_PORTAL_URL |
-- | "" |
| Admin users | admin_users |
VIRE_ADMIN_USERS |
-- | "" |
| Service key | service.key |
VIRE_SERVICE_KEY |
-- | "" |
| Portal ID | service.portal_id |
VIRE_PORTAL_ID |
-- | hostname |
| Environment | environment |
VIRE_ENV |
-- | prod |
| Log level | logging.level |
VIRE_LOG_LEVEL |
-- | info |
| Log format | logging.format |
VIRE_LOG_FORMAT |
-- | text |
| Log outputs | logging.outputs |
-- | -- | ["console", "file"] |
| Log file path | logging.file_path |
-- | -- | logs/vire-portal.log |
| Log max size (MB) | logging.max_size_mb |
-- | -- | 10 |
| Log max backups | logging.max_backups |
-- | -- | 5 |
The config file is auto-discovered from vire-portal.toml or docker/vire-portal.toml. Specify explicitly with -c path/to/config.toml.
The [api] section configures the MCP proxy. api.url points to the vire-server instance. User context is injected as X-Vire-* headers on every proxied request. All user data is managed by vire-server.
MCP Endpoint
The portal hosts an MCP (Model Context Protocol) server at POST /mcp using mcp-go with Streamable HTTP transport. Claude and other MCP clients connect to this endpoint to access investment tools.
Architecture
Claude Code / CLI Claude Desktop
| |
| POST /mcp (Streamable HTTP) | stdio
| |
| vire-mcp (bridge)
| |
| | POST /mcp (HTTP + OAuth)
v v
vire-portal (:8881)
| internal/mcp/ package
| - Dynamic tool catalog from GET /api/mcp/tools
| - Generic proxy handler (path/query/body params)
| - X-Vire-* header injection from JWT sub claim
| - OAuth 2.1 (DCR, PKCE, token exchange)
v
vire-server (:8080)
At startup, the portal fetches the tool catalog from vire-server's GET /api/mcp/tools endpoint with retry (3 attempts, 2s backoff). Each catalog entry defines the tool name, description, HTTP method, URL path template, and parameters. The portal validates each entry (non-empty name/method/path, method whitelist, /api/ path prefix, no path traversal) and skips duplicates. Valid tools are dynamically registered as MCP tools and routed to the appropriate REST endpoints. If vire-server is unreachable after all retries, the portal starts with 0 tools (non-fatal).
All tool calls are proxied to vire-server. The portal does not parse or format responses -- it returns raw JSON from vire-server, letting the MCP client (Claude) format the output.
Connecting Claude
The portal serves MCP over Streamable HTTP at POST /mcp with OAuth 2.1 authentication. All tool logic, catalog management, and version handling live in vire-portal. Clients connect either directly via HTTP (Claude Code, Claude CLI) or via the vire-mcp stdio bridge (Claude Desktop).
Claude Desktop → stdio → vire-mcp → HTTP + OAuth → vire-portal (:8881/mcp) → vire-server
Claude CLI/Web → HTTP + OAuth → vire-portal (:8881/mcp) → vire-server
Transport Compatibility
| Client | Transport | How to Connect |
|---|---|---|
| Claude Code (CLI) | Streamable HTTP | Direct HTTP URL to portal |
Claude Desktop (claude_desktop_config.json) |
stdio | vire-mcp binary or Docker |
| Claude Desktop (Connectors UI) | HTTPS | Public HTTPS URL (production) |
Claude Code (CLI)
Claude Code supports Streamable HTTP and MCP OAuth natively. On first connection, Claude Code runs the OAuth flow automatically (opens browser, user logs in, receives token).
Add to ~/.claude.json (global) or project .mcp.json (per-project):
{
"mcpServers": {
"vire": {
"type": "url",
"url": "http://localhost:8881/mcp"
}
}
}
For a deployed portal with HTTPS:
{
"mcpServers": {
"vire": {
"type": "url",
"url": "https://portal.vire.app/mcp"
}
}
}
Claude Desktop (local dev via vire-mcp)
Claude Desktop requires stdio transport for local MCP servers. vire-mcp is a stdio-to-HTTP bridge — it connects to vire-portal as an MCP client, discovers tools, and re-exposes them over stdio.
Two connection modes:
| Mode | Environment Variable | Auth | Use Case |
|---|---|---|---|
| OAuth | VIRE_PORTAL_URL |
Browser OAuth flow, tokens cached | Local dev with interactive login |
| Direct | VIRE_MCP_URL |
Encrypted UID in URL (no browser) | Docker, CI/CD, headless environments |
Configuration:
vire-mcp auto-discovers its TOML config from vire-mcp.toml or config/vire-mcp.toml (binary-relative paths checked first). Environment variables override config values:
| Env Var | Default | Description |
|---|---|---|
VIRE_PORTAL_URL |
http://localhost:8080 |
vire-portal URL (OAuth mode) |
VIRE_MCP_URL |
— | Full MCP endpoint URL with encrypted UID (direct mode, bypasses OAuth) |
VIRE_LOG_LEVEL |
info |
Log level (debug, info, warn, error) |
Logs are written to bin/logs/vire-mcp.log (relative to the binary). The startup log shows the resolved URL and mode:
level=INF message="loaded configuration" direct_mode=true mcp_url="http://localhost:8881/mcp/abc123..."
level=INF message="loaded configuration" direct_mode=false portal_url="http://localhost:8881"
Direct Mode (Recommended for Docker/WSL)
Direct mode uses an encrypted MCP endpoint URL that embeds user identity, bypassing OAuth entirely. Get your unique endpoint URL from the vire-portal dashboard (Profile > MCP Configuration).
{
"mcpServers": {
"vire": {
"command": "/path/to/bin/vire-mcp",
"env": {
"VIRE_MCP_URL": "http://localhost:8881/mcp/YOUR_ENCRYPTED_UID",
"VIRE_LOG_LEVEL": "error"
}
}
}
}
OAuth Mode (Interactive Login)
OAuth mode runs a browser flow on first launch; tokens are cached in ~/.vire/credentials.json.
{
"mcpServers": {
"vire": {
"command": "/path/to/bin/vire-mcp",
"env": {
"VIRE_PORTAL_URL": "http://localhost:8881"
}
}
}
}
WSL on Windows (Direct Mode)
When running on Windows with vire-mcp in WSL, use a shell wrapper to pass environment variables (the env object isn't passed through wsl -e):
{
"mcpServers": {
"vire": {
"command": "wsl",
"args": [
"-e",
"/bin/bash",
"-c",
"VIRE_MCP_URL=http://localhost:8881/mcp/YOUR_ENCRYPTED_UID VIRE_LOG_LEVEL=error /home/bobmc/development/vire-portal/bin/vire-mcp"
]
}
}
}
Note: The
envobject in Claude Desktop config doesn't pass variables throughwsl -e. Use/bin/bash -c "VAR=val command"instead.
Docker (Direct Mode - Ephemeral)
Direct mode is ideal for Docker: no OAuth, no credential mounts, completely ephemeral:
{
"mcpServers": {
"vire": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"--network", "host",
"-e", "VIRE_MCP_URL=http://localhost:8881/mcp/YOUR_ENCRYPTED_UID",
"-e", "VIRE_LOG_LEVEL=error",
"ghcr.io/bobmcallan/vire-mcp:latest"
]
}
}
}
Docker (OAuth Mode - Persistent Credentials)
OAuth mode requires a volume mount to persist tokens across container runs:
{
"mcpServers": {
"vire": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"--network", "host",
"-e", "VIRE_PORTAL_URL=http://localhost:8881",
"-v", "~/.vire:/root/.vire",
"ghcr.io/bobmcallan/vire-mcp:latest"
]
}
}
}
Note: For remote portals, replace
localhost:8881with your portal URL (e.g.,https://portal.vire.app).
Claude Desktop (production via Connectors)
For a deployed portal with HTTPS (e.g. https://portal.vire.app), add the server via Settings > Connectors in Claude Desktop. Connectors supports HTTPS URLs and handles OAuth natively — no vire-mcp bridge needed.
MCP OAuth 2.1 Flow
The portal implements OAuth 2.1 (RFC 9728) with PKCE S256 and Dynamic Client Registration. This flow is triggered automatically by mcp-remote or Claude Desktop Connectors:
1. Client discovers endpoints: GET /.well-known/oauth-authorization-server
2. Client registers: POST /register (Dynamic Client Registration)
3. Client redirects user: GET /authorize?client_id=...&code_challenge=...&state=...
4. Portal creates a pending session, sets mcp_session_id cookie, redirects to /
5. User logs in (email/password or Google/GitHub OAuth)
6. On login success, portal detects mcp_session_id cookie
7. Portal creates auth code, redirects to client's redirect_uri
8. Client exchanges code + code_verifier: POST /token
9. Portal verifies PKCE S256, mints JWT access token (1h) + refresh token (7d)
10. Client sends Authorization: Bearer <jwt> on all subsequent MCP requests
Authentication Chain
Claude (Bearer token or cookie)
-> POST /mcp
-> vire-portal: extract user ID from JWT "sub" claim
-> proxy to vire-server with X-Vire-User-ID header
-> vire-server: look up user's Navexa key from DB
-> call Navexa API with user's key
The portal extracts user identity from either:
Authorization: Bearer <jwt>header (Claude Desktop via mcp-remote / Connectors)vire_sessioncookie (web dashboard)
Bearer token takes priority. If neither is present, the request proceeds without user context.
Tools
Tools are registered dynamically from vire-server's GET /api/mcp/tools catalog at startup. The catalog defines each tool's name, description, HTTP method, URL path template, and parameters (path, query, body). The portal builds MCP tool definitions and generic handlers from the catalog entries. See the vire-server README for the full tool catalog.
X-Vire-* Headers
The proxy injects these headers on every request to vire-server:
| Header | Source | Description |
|---|---|---|
X-Vire-Portfolios |
VIRE_DEFAULT_PORTFOLIO env var |
Comma-separated portfolio names |
X-Vire-Display-Currency |
VIRE_DISPLAY_CURRENCY env var |
Currency for display values |
X-Vire-User-ID |
Session cookie (per-request) | Username from JWT sub claim |
Static headers are set from environment variables on every request. Per-request headers are set when a vire_session cookie is present -- the handler decodes the JWT sub claim and injects the user ID. vire-server resolves the user's navexa key internally from the user ID.
Authentication Flow
The portal authenticates users via vire-server. Three login methods are supported: email/password, Google OAuth, and GitHub OAuth. vire-server handles credential validation and token exchange; the portal never touches passwords or OAuth secrets directly.
Email/Password Login
1. User submits email + password on the landing page
2. Portal forwards to vire-server: POST /api/auth/login { username, password }
3. vire-server validates credentials, returns a signed JWT
4. Portal sets the JWT as an httpOnly "vire_session" cookie
5. User is redirected to /dashboard
OAuth Login (Google / GitHub)
1. User clicks "Sign in with Google" (or GitHub) on /
-> Browser navigates to portal: GET /api/auth/login/google
2. Portal makes a server-side request to vire-server: GET {API_URL}/api/auth/login/google?callback={callbackURL}
3. vire-server returns a 302 redirect to the OAuth provider's consent screen
4. Portal forwards the redirect Location to the browser (never exposing internal API URLs)
5. User authorises, provider redirects back to vire-server
6. vire-server exchanges code for tokens, creates/updates user, mints a JWT
7. vire-server redirects to portal: GET /auth/callback?token=<jwt>
8. Portal sets the JWT as an httpOnly "vire_session" cookie
9. User is redirected to /dashboard
The server-side proxy prevents internal Docker addresses (like http://server:8080) from being exposed to the browser.
The callback_url config setting tells the portal where vire-server should redirect after OAuth completes. This must match the URL registered with each OAuth provider.
MCP OAuth 2.1 Flow (Claude Desktop)
When Claude Desktop connects, it performs a full OAuth 2.1 flow with PKCE:
1. Claude Desktop discovers endpoints: GET /.well-known/oauth-authorization-server
2. Claude Desktop registers: POST /register (Dynamic Client Registration)
3. Claude Desktop redirects user: GET /authorize?client_id=...&code_challenge=...&state=...
4. Portal creates a pending session, sets mcp_session_id cookie, redirects to /
5. User logs in (email/password or OAuth — same flows as above)
6. On login success, portal detects mcp_session_id cookie
7. Portal calls CompleteAuthorization: creates auth code, redirects to Claude Desktop's redirect_uri
8. Claude Desktop exchanges code + code_verifier: POST /token
9. Portal verifies PKCE S256, mints a JWT access token (1h) + refresh token (7d)
10. Claude Desktop sends Bearer token on all subsequent MCP requests
JWT Claims
Tokens minted by the portal OAuth server contain:
| Claim | Description |
|---|---|
sub |
User ID (from vire-server) |
scope |
Granted scopes (e.g. openid portfolio:read tools:invoke) |
client_id |
OAuth client ID |
iss |
Portal base URL |
iat / exp |
Issued-at / expiry (1 hour) |
Tokens are HMAC-SHA256 signed using the auth.jwt_secret config value.
OAuth Provider Configuration
| Provider | Scopes |
|---|---|
openid, email, profile |
|
| GitHub | read:user, user:email |
API Contract with vire-server
The portal communicates exclusively with vire-server's REST API. All request/response bodies are JSON.
Error Response Format
All endpoints return errors in a consistent shape:
{
"error": {
"code": "invalid_key",
"message": "EODHD API returned 401 -- check that the key is correct and has an active subscription"
}
}
| HTTP Status | Meaning | Portal Action |
|---|---|---|
| 400 | Bad request (missing required fields) | Show validation error |
| 401 | JWT expired or invalid | Attempt token refresh; if refresh fails, redirect to / |
| 403 | Forbidden (account suspended) | Show account status message |
| 404 | Resource not found | Show "not found" state |
| 409 | Conflict (e.g., already provisioned) | Handle idempotently (show existing resource) |
| 422 | Validation failed (e.g., invalid API key) | Show field-level error from response |
| 429 | Rate limited | Show retry message |
| 500 | Server error | Show generic error with retry option |
Auth Routes (Unauthenticated)
GET /api/auth/login/:provider
Proxies the OAuth redirect through vire-server. The portal makes a server-side HTTP request to vire-server, which returns a 302 redirect to the OAuth provider. The portal forwards the redirect Location to the browser. This prevents internal Docker addresses from reaching the browser.
| Parameter | Type | Description |
|---|---|---|
:provider |
path | google or github |
Response: 302 redirect to provider OAuth URL (forwarded from vire-server). On error: 302 redirect to /error?reason=auth_unavailable or /error?reason=auth_failed.
POST /api/auth/callback
Exchanges an OAuth authorization code for a session. The state parameter is generated by the gateway during GET /api/auth/login/:provider and passed through the OAuth flow.
Request body:
{
"provider": "google",
"code": "4/0AY0e-g...",
"state": "value-from-callback-query-params"
}
Response (200):
{
"token": "eyJhbG...",
"user": {
"user_id": "uuid",
"email": "alice@example.com",
"display_name": "Alice",
"avatar_url": "https://...",
"auth_provider": "google",
"created_at": "2026-02-09T10:00:00Z",
"status": "active",
"keys_configured": false,
"plan": "free"
}
}
Also sets refresh_token as httpOnly, Secure, SameSite=Lax cookie.
POST /api/auth/refresh
Refreshes an expired JWT using the httpOnly refresh token cookie.
Request body: None (cookie sent automatically).
Response (200):
{
"token": "eyJhbG..."
}
Response (401): Refresh token expired or invalid. User must re-authenticate.
POST /api/auth/logout
Clears the refresh token cookie and invalidates the session.
Response (200):
{
"status": "ok"
}
Profile Routes (JWT Required)
GET /api/profile
Returns the authenticated user's profile.
Response (200):
{
"user_id": "uuid",
"email": "alice@example.com",
"display_name": "Alice",
"avatar_url": "https://...",
"auth_provider": "google",
"created_at": "2026-02-09T10:00:00Z",
"status": "active",
"keys_configured": true,
"default_portfolio": "SMSF",
"portfolios": ["SMSF", "Personal"],
"exchange": "AU",
"plan": "pro",
"proxy_url": "https://vire-mcp-a1b2c3-xyz.run.app",
"proxy_status": "running",
"provisioned_at": "2026-02-09T10:01:00Z"
}
PUT /api/profile
Updates user preferences.
Request body (partial update -- include only fields to change):
{
"default_portfolio": "Personal",
"portfolios": ["SMSF", "Personal"],
"exchange": "AU"
}
Editable fields: default_portfolio, portfolios, exchange. Other profile fields (log_level, display_name, avatar_url) exist in the Firestore data model but are not exposed for portal editing in Stage 1. Identity fields (email, auth_provider, user_id) and infrastructure fields (proxy_url, provisioned_at) are always read-only.
Response (200): Updated profile object (same shape as GET /api/profile).
DELETE /api/profile
Deletes the user account. Triggers de-provisioning of MCP proxy, secret cleanup, and data deletion (30-day grace period).
Response (200):
{
"status": "deleted",
"grace_period_ends": "2026-03-11T10:00:00Z"
}
API Key Routes (JWT Required)
PUT /api/profile/keys
Sets or updates one or more API keys (BYOK). The gateway validates each key against the provider's API before storing. Keys are stored in Secret Manager, never in Firestore.
Request body:
{
"eodhd_key": "abc123...",
"navexa_key": "def456...",
"gemini_key": "ghi789..."
}
All fields are optional -- include only the keys to set or update.
Response (200):
{
"eodhd_key": {
"status": "valid",
"last4": "c123",
"validated_at": "2026-02-09T10:05:00Z"
},
"navexa_key": {
"status": "valid",
"last4": "f456",
"validated_at": "2026-02-09T10:05:00Z",
"portfolios_found": 2
},
"gemini_key": {
"status": "valid",
"last4": "i789",
"validated_at": "2026-02-09T10:05:00Z"
}
}
Response (422) -- validation failed:
{
"eodhd_key": {
"status": "invalid",
"error": "API returned 401 -- check that the key is correct and has an active subscription"
}
}
Key validation methods:
| Provider | Validation | Endpoint |
|---|---|---|
| EODHD | GET /api/exchanges-list/?api_token={key} |
eodhistoricaldata.com |
| Navexa | GET /v1/portfolios with X-API-Key header |
navexa.io |
| Gemini | models.list via SDK |
generativelanguage.googleapis.com |
DELETE /api/profile/keys/:id
Removes a specific API key.
| Parameter | Type | Description |
|---|---|---|
:id |
path | eodhd_key, navexa_key, or gemini_key |
Response (200):
{
"status": "removed",
"key_name": "navexa_key"
}
Provisioning Routes (JWT Required)
POST /api/profile/provision
Provisions a dedicated MCP proxy (Cloud Run service) for the user. Requires at least eodhd_key to be configured.
Response (200):
{
"status": "provisioned",
"proxy_url": "https://vire-mcp-a1b2c3-xyz123abc-ts.a.run.app",
"provisioned_at": "2026-02-09T10:01:00Z"
}
Response (400): EODHD key not configured.
Response (409): Already provisioned (returns existing proxy_url).
GET /api/profile/mcp
Returns MCP connection configuration blocks ready for copy-paste.
Response (200):
{
"proxy_url": "https://vire-mcp-a1b2c3-xyz123abc-ts.a.run.app",
"claude_code_config": {
"mcpServers": {
"vire": {
"type": "http",
"url": "https://vire-mcp-a1b2c3-xyz123abc-ts.a.run.app/mcp"
}
}
},
"claude_desktop_config": {
"mcpServers": {
"vire": {
"url": "https://vire-mcp-a1b2c3-xyz123abc-ts.a.run.app/mcp"
}
}
}
}
GET /api/profile/status
Returns the health and activity status of the user's MCP proxy.
Response (200):
{
"proxy_status": "running",
"last_activity": "2026-02-09T12:30:00Z",
"proxy_url": "https://vire-mcp-a1b2c3-xyz123abc-ts.a.run.app"
}
Possible proxy_status values: running, stopped, not_provisioned, throttled.
Usage Routes (JWT Required)
GET /api/usage
Returns usage statistics for the current billing period.
Response (200):
{
"period": "2026-02",
"total_requests": 3421,
"quota_limit": 10000,
"quota_remaining": 6579,
"status": "active",
"daily_counts": [
{ "date": "2026-02-01", "count": 120 },
{ "date": "2026-02-02", "count": 185 }
],
"top_endpoints": [
{ "endpoint": "portfolio_compliance", "count": 842 },
{ "endpoint": "get_summary", "count": 521 },
{ "endpoint": "compute_indicators", "count": 498 }
]
}
Billing Routes (JWT Required)
POST /api/billing/checkout
Creates a Stripe Checkout session for upgrading to Pro.
Response (200):
{
"checkout_url": "https://checkout.stripe.com/c/pay/..."
}
Frontend redirects the browser to checkout_url via window.location.href = checkout_url. Stripe handles payment. On completion, Stripe redirects back to https://${DOMAIN}/billing?session_id={CHECKOUT_SESSION_ID} -- the gateway configures this return URL when creating the session. The gateway receives a Stripe webhook to update the user's plan.
POST /api/billing/portal
Creates a Stripe Billing Portal session for managing subscription, viewing invoices, and cancelling.
Response (200):
{
"portal_url": "https://billing.stripe.com/p/session/..."
}
Frontend redirects the browser to portal_url.
BYOK -- Bring Your Own Keys
Users must provide their own API keys. Vire does not proxy or resell API access.
Key Configuration UX
The Profile page displays three key fields:
| Service | Required | What It Provides | Where to Get a Key |
|---|---|---|---|
| EODHD | Yes | Stock prices, fundamentals, screening | eodhistoricaldata.com -> Pricing -> API key |
| Navexa | No | Portfolio sync from your broker | navexa.io -> Settings -> API |
| Google Gemini | No | AI-powered filings analysis, news intelligence | aistudio.google.com/apikey |
Display States
Each key field shows one of (80s B&W design -- no colours, text-only indicators):
- Not configured -- dashed border, "NOT SET" label, empty input with "ADD" button
- Configured -- solid border,
[OK] ::::abcdshowing last 4 characters, "UPDATE" and "DELETE" buttons - Invalid -- solid border,
[ERR]label with error message from validation
Security
- Keys are encrypted with Cloud KMS and stored in GCP Secret Manager
- Keys are never stored in Firestore (profile holds only a reference)
- Keys are never logged or shared
- After entry, only the last 4 characters are visible
- Each key is injected directly into the user's dedicated MCP proxy as a Cloud Run secret reference
Dockerfile
Located at docker/Dockerfile. Multi-stage build matching the vire ecosystem pattern. Stage 1 builds the Go binary; stage 2 runs it on Alpine.
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /build
ARG VERSION=dev
ARG BUILD=unknown
ARG GIT_COMMIT=unknown
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w \
-X 'github.com/bobmcallan/vire-portal/internal/config.Version=${VERSION}' \
-X 'github.com/bobmcallan/vire-portal/internal/config.Build=${BUILD}' \
-X 'github.com/bobmcallan/vire-portal/internal/config.GitCommit=${GIT_COMMIT}'" \
-o vire-portal ./cmd/vire-portal
# Runtime stage
FROM alpine:3.21
LABEL org.opencontainers.image.source="https://github.com/bobmcallan/vire-portal"
WORKDIR /app
RUN apk --no-cache add ca-certificates wget
COPY --from=builder /build/vire-portal .
COPY --from=builder /build/pages ./pages
COPY --from=builder /build/docker/vire-portal.toml .
COPY .version .
RUN mkdir -p /app/logs
EXPOSE 8080
HEALTHCHECK NONE
ENTRYPOINT ["./vire-portal"]
GitHub Actions Workflows
Test (.github/workflows/test.yml): Runs on PR and push to main. Two jobs: unit-test (go vet + go test ./internal/...) and ui-test (headless Chrome via ./scripts/ui-test.sh all). Test artifacts are uploaded on failure.
Release (.github/workflows/release.yml): Builds and pushes Docker images for vire-portal and vire-mcp to GHCR on push to main or version tags (matrix strategy):
- Extracts version from
.versionfile - Passes VERSION, BUILD, GIT_COMMIT as Docker build args
- Tags:
latest(on main), semantic version, short SHA - Uses GitHub Actions cache for Docker layers
.version File
The .version file at the project root is the single source of truth:
version: 0.1.2
build: 02-14-20-27-29
version:is the semantic versionbuild:is the timestamp of the last build, updated automatically by build/deploy scripts- Both
deploy.shandbuild.shinject VERSION, BUILD, and GIT_COMMIT as Docker build args - The CI workflow (
release.yml) uses the same version extraction pattern
Data Layer
The portal is stateless -- it does not store any user data locally. All user profiles and settings are managed by vire-server via its REST API:
GET /api/users/{id}-- fetch user profile (username, email, role, navexa key status)PUT /api/users/{id}-- update user fields (e.g., navexa key)
The API client is in internal/client/vire_client.go.
Project Structure
vire-portal/
├── .github/
│ └── workflows/
│ ├── release.yml # Docker build + GHCR push (matrix: portal + mcp)
│ └── test.yml # CI: go vet, unit tests, UI tests on PR/push
├── cmd/
│ ├── vire-portal/
│ │ └── main.go # Portal entry point (flag parsing, config, graceful shutdown)
│ └── vire-mcp/
│ ├── main.go # Stdio-to-HTTP bridge (connects to vire-portal)
│ ├── oauth.go # OAuth 2.1 browser flow + local callback server
│ └── tokenstore.go # File-based OAuth token persistence (~/.vire/credentials.json)
├── internal/
│ ├── app/
│ │ └── app.go # Dependency container (Config, Logger, Handlers, OAuthServer)
│ ├── auth/
│ │ ├── authorize.go # GET /authorize handler (PKCE, session tracking, auto-register)
│ │ ├── dcr.go # POST /register handler (RFC 7591 Dynamic Client Registration)
│ │ ├── discovery.go # .well-known OAuth discovery endpoints
│ │ ├── pkce.go # PKCE S256 verification (constant-time compare)
│ │ ├── server.go # OAuthServer (central state, JWT minting, auth completion)
│ │ ├── session.go # SessionStore for pending MCP auth sessions (TTL 10 min)
│ │ ├── store.go # ClientStore, CodeStore, TokenStore (in-memory, mutex-protected)
│ │ └── token.go # POST /token handler (auth_code + refresh_token grants)
│ ├── config/
│ │ ├── config.go # TOML loading with defaults -> file -> env -> CLI priority
│ │ ├── config_test.go
│ │ ├── defaults.go # Default configuration values
│ │ ├── version.go # Version info (ldflags + .version file)
│ │ └── version_test.go
│ ├── handlers/
│ │ ├── auth.go # OAuth auth handlers (dev login, Google/GitHub redirects, callback, logout, JWT validation)
│ │ ├── auth_test.go # Auth handler tests (ValidateJWT, IsLoggedIn, OAuth flows)
│ │ ├── auth_integration_test.go # Integration tests (full login round-trip, OAuth chains)
│ │ ├── auth_stress_test.go # Security stress tests (alg:none attack, tampering, timing, hostile inputs)
│ │ ├── dashboard.go # GET /dashboard (portfolio management, holdings)
│ │ ├── strategy.go # GET /strategy (portfolio strategy and plan editors)
│ │ ├── mcp_page.go # GET /mcp-info (MCP connection config, tools catalog)
│ │ ├── handlers_test.go
│ │ ├── health.go # GET /api/health
│ │ ├── helpers.go # WriteJSON, RequireMethod, WriteError
│ │ ├── landing.go # PageHandler (template rendering + static file serving)
│ │ ├── profile.go # GET/POST /profile (user info + Navexa API key management)
│ │ └── version.go # GET /api/version
│ ├── cache/
│ │ ├── cache.go # API response cache (TTL, max entries, prefix invalidation)
│ │ └── cache_test.go
│ ├── client/
│ │ ├── vire_client.go # HTTP client for vire-server user API (GetUser, UpdateUser)
│ │ └── vire_client_test.go
│ ├── mcp/
│ │ ├── catalog.go # Dynamic tool catalog types, FetchCatalog, BuildMCPTool, GenericToolHandler
│ │ ├── context.go # UserContext (per-request user identity for proxy headers)
│ │ ├── handler.go # MCP HTTP handler (Streamable HTTP + JWT auth, catalog fetch at startup)
│ │ ├── handler_test.go # Tests: withUserContext, extractJWTSub
│ │ ├── handler_stress_test.go # Stress tests: hostile cookies, concurrent access, binary garbage
│ │ ├── handlers.go # errorResult helper, resolvePortfolio
│ │ ├── mcp_test.go # Tests: catalog, validation, tools, handlers, proxy, integration
│ │ ├── proxy.go # HTTP proxy to vire-server with X-Vire-* headers
│ │ ├── tools.go # RegisterToolsFromCatalog (dynamic registration)
│ │ ├── version.go # Combined get_version handler (vire_portal + vire_server)
│ │ └── version_test.go # Version handler tests
│ ├── server/
│ │ ├── middleware.go # Correlation ID, logging, CORS, recovery
│ │ ├── middleware_test.go
│ │ ├── route_helpers.go # RouteByMethod, RouteResourceCollection
│ │ ├── route_helpers_test.go
│ │ ├── routes.go # Route registration
│ │ ├── routes_test.go
│ │ └── server.go # HTTP server (net/http, timeouts, graceful shutdown)
│ └── vire/ # Shared packages (migrated from vire repo)
│ ├── common/ # Version, logging, config, formatting helpers
│ ├── interfaces/ # Service and storage interface contracts
│ └── models/ # Data structures (portfolio, market, strategy, etc.)
├── tests/
│ ├── common/ # Test utilities (browser, config, containers, screenshot)
│ │ ├── browser.go # chromedp helpers (NewBrowserContext, NavigateAndWait, etc.)
│ │ ├── containers.go # Docker test env (SurrealDB + vire-server + portal via testcontainers)
│ │ ├── testconfig.go # Test config loader (TOML, results dir, timestamps)
│ │ └── screenshot.go # Screenshot capture utility
│ ├── docker/ # Docker configs for test environment
│ │ ├── docker-compose.yml # Full test stack (SurrealDB + vire-server + portal)
│ │ ├── Dockerfile.server # Portal test image (multi-stage build)
│ │ └── portal-test.toml # Portal test configuration
│ └── ui/ # UI browser tests
│ ├── test_config.toml # Test configuration (server URL, browser settings)
│ ├── ui_helpers_test.go # Test helpers (newBrowser, isVisible, etc.)
│ ├── smoke_test.go # Smoke tests (landing, dashboard, branding)
│ ├── dashboard_test.go # Dashboard tests (sections, panels, growth chart, design rules)
│ ├── strategy_test.go # Strategy page tests (editors, nav, portfolio selector)
│ ├── capital_test.go # Capital page tests (cash transactions, pagination, summary)
│ ├── nav_test.go # Navigation tests (hamburger, dropdown, mobile)
│ └── auth_test.go # Auth tests (Google/GitHub login redirects)
├── pages/
│ ├── dashboard.html # Dashboard page (portfolio selector, holdings, capital performance, indicators, growth chart, refresh)
│ ├── strategy.html # Strategy page (portfolio strategy and plan editors)
│ ├── cash.html # Cash page (cash transactions ledger, paged table)
│ ├── mcp.html # MCP info page (connection details, tools table)
│ ├── landing.html # Landing page (Go html/template)
│ ├── profile.html # Profile page (user info + Navexa API key management)
│ ├── partials/
│ │ ├── head.html # HTML head (IBM Plex Mono, Chart.js CDN, Alpine.js CDN)
│ │ ├── nav.html # Navigation bar
│ │ └── footer.html # Footer
│ └── static/
│ ├── css/
│ │ └── portal.css # 80s B&W aesthetic (no border-radius, no box-shadow)
│ └── common.js # Client logging, Alpine.js init, vireStore (fetch cache/dedup), portfolioDashboard() (growth chart), cashTransactions(), portfolioStrategy()
├── docker/
│ ├── Dockerfile # Portal multi-stage build (golang:1.25 -> alpine)
│ ├── Dockerfile.mcp # MCP stdio binary build (golang:1.25 -> alpine)
│ ├── docker-compose.yml # Portal + vire-server stack
│ ├── docker-compose.dev.yml # Dev overlay (VIRE_ENV=dev, used by deploy.sh local)
│ ├── docker-compose.ghcr.yml # GHCR pull + watchtower auto-update
│ ├── vire-portal.toml # Portal configuration
│ └── README.md # Docker usage documentation
├── docs/
│ ├── requirements.md # API contracts and architecture
│ ├── architecture-comparison.md
│ └── authentication/
│ └── mcp-oauth-implementation-steps.md # MCP OAuth implementation plan (Phase 1 complete)
├── scripts/
│ ├── deploy.sh # Deploy orchestration (local/ghcr/down/prune)
│ ├── build.sh # Docker image builder (--portal, --mcp, or both)
│ ├── run.sh # Build + start/stop/restart server locally
│ ├── ui-test.sh # UI test runner (smoke, dashboard, nav, auth, all)
│ ├── verify-auth.sh # Auth endpoint validation (health, login, OAuth, MCP)
│ └── test-scripts.sh # Validation suite for scripts and configs
├── .dockerignore
├── .version # Version metadata (source of truth)
├── go.mod
├── go.sum
├── .gitignore
├── LICENSE
└── README.md
Docker (local)
The project includes deployment scripts matching the vire project patterns. See docker/README.md for full details.
Service Stack (portal + vire-server)
The recommended deployment runs portal and vire-server together via docker-compose:
# Build portal and start services (uses dev compose overlay)
./scripts/deploy.sh local
This starts:
- vire-portal on port 8881 -- landing page + MCP endpoint (dev mode)
- vire-server on port 8882 -- backend API (pulled from GHCR)
Local deploys automatically use docker-compose.dev.yml as a compose overlay, which sets VIRE_ENV=dev to enable dev mode (dev login, etc.). The base docker-compose.yml stays unchanged for prod-like builds.
The portal connects to vire-server via VIRE_API_URL=http://vire-server:8080 (Docker internal network). Claude Code connects to http://localhost:8881/mcp (portal). Claude Desktop uses vire-mcp via stdio (see Connecting Claude section above).
Portal Only
# Build and run portal standalone (MCP proxy will fail without vire-server)
./scripts/deploy.sh local
# Build with forced rebuild (no cache)
./scripts/deploy.sh local --force
# Deploy from GHCR with watchtower auto-update
./scripts/deploy.sh ghcr
# Stop all containers
./scripts/deploy.sh down
# Prune stopped containers and dangling images
./scripts/deploy.sh prune
Alternatively, build a standalone image without docker-compose:
# Build Docker image with version injection
./scripts/build.sh
# Build with verbose output
./scripts/build.sh --verbose
# Clean existing images and rebuild
./scripts/build.sh --clean
Or use docker directly:
# Build the Docker image
docker build -f docker/Dockerfile -t vire-portal:latest .
# Run on host port 8881
docker run -p 8881:8080 \
-e VIRE_SERVER_HOST=0.0.0.0 \
-e VIRE_API_URL=http://host.docker.internal:8080 \
vire-portal:latest
Cloud Run Deployment
The portal runs on Cloud Run, deployed via vire-infra Terraform. The Terraform module is at infra/modules/portal/main.tf in the vire-infra repo.
Configuration from Terraform
# Simplified from vire-infra/infra/modules/portal/main.tf
resource "google_cloud_run_v2_service" "portal" {
name = "vire-portal"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
template {
scaling {
min_instance_count = 0
max_instance_count = 3
}
containers {
image = "ghcr.io/bobmcallan/vire-portal:latest"
ports { container_port = 8080 }
env { name = "VIRE_SERVER_HOST"; value = "0.0.0.0" }
env { name = "VIRE_SERVER_PORT"; value = "8080" }
env { name = "VIRE_LOG_LEVEL"; value = "info" }
resources {
limits = { cpu = "1", memory = "256Mi" }
}
}
}
}
Key properties:
- Image:
ghcr.io/bobmcallan/vire-portal:latest(published by this repo's GitHub Actions) - Port: 8080
- Ingress: All (public website)
- Auth: Unauthenticated (public access via IAM allUsers)
- Scaling: 0-3 instances (scales to zero when idle)
- Resources: 1 CPU, 256Mi memory
Releasing
Push a version tag to trigger the GitHub Actions workflow:
git tag v0.1.2
git push origin v0.1.2
This builds and pushes ghcr.io/bobmcallan/vire-portal with the version tag and :latest to GHCR.
Pushing to main also triggers a build with the :latest tag. You can trigger a build manually from the Actions tab using "Run workflow".
The vire-infra Terraform references ghcr.io/bobmcallan/vire-portal:latest. After a new image is pushed, re-apply Terraform or update the Cloud Run service to pull the new image.
Architecture Context
The portal runs alongside vire-server in a Docker Compose stack. vire-mcp is an ephemeral stdio-to-HTTP bridge launched by Claude Desktop:
┌───────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ User │ │ Claude Code / │ │ Claude Desktop │
│ (browser) │ │ Claude CLI │ │ │
└───────┬───────┘ └────────┬──────────┘ └────────┬─────────┘
│ GET / │ HTTP + OAuth │ stdio
│ │ │
│ │ ┌────────┴─────────┐
│ │ │ vire-mcp │
│ │ │ - stdio ↔ HTTP │
│ │ │ - OAuth 2.1 │
│ │ │ - Token cache │
│ │ └────────┬─────────┘
│ │ │ HTTP + OAuth
┌────────┴─────────────────────┴────────────────────────┴─────────┐
│ vire-portal (:8881) │
│ - Landing page, dashboard, strategy, profile │
│ - MCP endpoint (Streamable HTTP at POST /mcp) │
│ - OAuth 2.1 (DCR, PKCE, token exchange) │
│ - Dynamic tool catalog from vire-server │
│ - X-Vire-* header injection, JWT auth │
└────────────────────────────┬────────────────────────────────────┘
│ REST API proxy
┌────────────────────────────┴────────────────────────────────────┐
│ vire-server (:8080) │
│ - Portfolio analysis, market data │
│ - Report generation, stock screening │
│ - Strategy and plan management │
└─────────────────────────────────────────────────────────────────┘
All tool logic lives in vire-portal. vire-mcp is a pure transport adapter — it does not import internal/mcp or contact vire-server directly. The portal implements OAuth 2.1 so both Claude Code and Claude Desktop (via vire-mcp) authenticate users and receive per-user JWT tokens. vire-server resolves the user's API keys (Navexa, EODHD, etc.) from the user ID in the X-Vire-User-ID header.
License
Private repository.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
vire-mcp
command
Command vire-mcp is a stdio-to-HTTP bridge for MCP.
|
Command vire-mcp is a stdio-to-HTTP bridge for MCP. |
|
vire-portal
command
|
|
|
internal
|
|
|
vire/common
Package common provides shared utilities for Vire.
|
Package common provides shared utilities for Vire. |
|
vire/interfaces
Package interfaces defines service contracts for Vire
|
Package interfaces defines service contracts for Vire |
|
vire/models
Package models defines data structures for Vire.
|
Package models defines data structures for Vire. |
|
Package tests contains migration validation tests for vire-portal.
|
Package tests contains migration validation tests for vire-portal. |