ValuAid Connect MCP Gateway
A small Model Context Protocol
(MCP) gateway in front of the ValuAid engine API (api.engine.valuaid.app).
Agents such as Claude Desktop, Claude Code, Cursor or ChatGPT talk streamable-HTTP MCP to the gateway;
the gateway authenticates them with valuaid-auth and calls the engine on their behalf.
The gateway holds no secrets and no service credential: it verifies the caller's own token against the auth server's public keys (JWKS) and forwards that same JWT unchanged to the engine. Authorization, billing and credit settlement stay authoritative in the engine.
At a glance
| Endpoint | https://mcp.connect.valuaid.app (paths / or /mcp) |
|---|---|
| Transport | Streamable HTTP, stateless, JSON responses — one POST per JSON-RPC message |
| Authentication | Authorization: Bearer — valuaid-auth JWT (RS256) or long-lived session token |
| Tools | 14 — 10 read, 4 write (writes are separately rate-limited and may cost credits) |
| OAuth | RFC 9728 protected-resource metadata; the login flow itself lives in valuaid-auth |
| Implementation | Server name valuaid-mcp-gateway, v0.1.0 — Bun, TypeScript, @modelcontextprotocol/sdk, jose, zod |
Architecture
The whole flow, one request at a time:
Claude · Cursor · ChatGPT
POST /mcp
verify JWT (JWKS) · rate-limit · validate input
POST /v1/…
api.engine.valuaid.app
GET /api/auth/token
auth.valuaid.app (JWKS + token exchange)
- Auth — the Bearer token is a valuaid-auth JWT (RS256; issuer and audience checked against the JWKS) or a long-lived session token, which the gateway exchanges for a JWT and caches until 30 s before expiry.
- Authorization — none of the gateway's own. The user's JWT is forwarded unchanged, so the engine (and OpenFGA behind it) decides what that user may see or change.
- Billing — the engine reserves and settles credits per billable input through ValuAid Meter, charged to the JWT's user. The gateway adds rate limits only: no wallet logic, no usage events. Engine refusals (credits, state) reach the agent as short status-based tool errors; upstream details are never exposed.
Transport & endpoints
The server is stateless (no MCP sessions): one POST to / or /mcp per
JSON-RPC 2.0 message, JSON responses, no standalone SSE stream and no session to delete.
Routes
| Route | Method | Auth | Behaviour |
|---|---|---|---|
/, /mcp | POST | per message | MCP streamable-HTTP endpoint |
/health | GET | none | {"status":"ok"} |
/.well-known/oauth-protected-resource/.well-known/oauth-protected-resource/mcp | GET, HEAD, OPTIONS | none | RFC 9728 metadata for OAuth clients (like ChatGPT) |
| anything else | — | — | 404 |
Request rules
Content-Type: application/jsonis required on the MCP path; anything else gets415.- The body cap (
MAX_BODY_BYTES) is enforced before JSON parsing; over the cap →413. - Invalid JSON →
400(−32700); a message that fails the JSON-RPC schema →400(−32600). - JSON-RPC batches are rejected — send one message per POST.
- GET on the MCP path →
405;OPTIONSis answered with CORS preflight (204).
Public methods
initialize, ping, tools/list and notifications/initialized are
public and do not contact the auth service, even if a Bearer header is supplied. All other messages
require authentication. On the wire, initialize advertises the server
valuaid-mcp-gateway (v0.1.0) together with the instructions that describe the
end-to-end valuation flow for the agent.
CORS
access-control-allow-origin: *; methods GET, POST, HEAD, OPTIONS;
allowed headers authorization, content-type, accept, mcp-protocol-version;
exposed headers www-authenticate, retry-after.
RFC 9728 metadata
GET /.well-known/oauth-protected-resource returns the resource URL,
authorization_servers: ["https://auth.valuaid.app"],
scopes_supported: ["openid","profile","email","offline_access"] and
bearer_methods_supported: ["header"].
Login, client registration, PKCE and token issuance are responsibilities of valuaid-auth —
metadata alone does not implement or verify that login flow. Header-based clients can send Bearer tokens directly.
Authentication
Every non-public MCP message carries Authorization: Bearer <token>. The token is one of two kinds:
1. JWT (RS256)
A backend JWT (≈ 5 minutes) minted by valuaid-auth. The gateway verifies it with
jose against the JWKS at https://auth.valuaid.app/api/auth/jwks
(JWKS cache 1 h, cooldown 30 s), pinned to algorithm RS256,
issuer https://auth.valuaid.app and the accepted audience set.
The token must carry sub and exp; the user id (sub) is the principal
used for rate limiting.
Accepted audiences by default: valuaid-api, https://mcp.connect.valuaid.app and
https://mcp.connect.valuaid.app/mcp; comma-separated values of
VALUAID_AUTH_AUDIENCE are added to these.
2. Session token
The long-lived set-auth-token value from sign-in (valid for the domain's session duration,
revocable with POST /api/auth/revoke-session). Tokens that do not look like a JWT are
exchanged at GET {auth}/api/auth/token for a fresh JWT, which is then verified as above.
Exchange results are cached until 30 s before expiry in a bounded cache
(max 5 000 entries, FIFO eviction) keyed by the SHA-256 of the token, with in-flight deduplication.
The raw session token is never stored as a cache key.
Failures
| Case | Response |
|---|---|
| Missing / bad token | 401 with WWW-Authenticate: Bearer realm="valuaid-mcp-gateway", resource_metadata="…/.well-known/oauth-protected-resource" |
| valuaid-auth unreachable or garbled | 502 “Authentication service unavailable” |
| Per-IP failed sign-ins (default 10/min) | 429 with Retry-After |
A bad credential is the caller's problem (401); anything wrong with the auth service itself is the
gateway's problem (502). The client IP is read from X-Forwarded-For when TRUST_PROXY=true.
Which token should a client send?
The easiest long-lived token is the account's session token; a freshly minted 5-minute JWT works too. The gateway holds no service credential — only the caller's own token is ever used.
Tool reference
A curated subset of the engine API, not a mirror: no delete, share, patch or job-retry tool exists.
Read tools carry the annotations readOnlyHint: true, idempotentHint: true,
destructiveHint: false, openWorldHint: false; write tools set readOnlyHint: false.
Shared parameters
| Parameter | Type | Meaning |
|---|---|---|
item_id, job_id, question_id | uuid | Validated as UUIDs before they ever enter a URL path. |
limit | integer 1–100 | Page size; per-tool defaults noted below. |
cursor | string ≤ 512 | Opaque cursor from a previous page's next_cursor. |
idempotency_key | string | Required on every write — see Idempotency. |
Errors
Tools never throw to the transport: failures come back as tool results with isError: true and
short, status-based text (input validation errors, engine refusals such as credits or state, rate-limit
messages with a retry hint, or a generic “The gateway could not complete the request.” for unexpected faults).
Upstream details are never exposed.
Read tools 10
list_items
List the valuation items you can read (your own and those shared with you), newest first.
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string ≤ 200 | — | Free-text search. |
scope | accessible | owned | — | accessible (default) or owned. |
intake_status | collecting | ready | consolidating | — | Filter by intake status. |
limit | integer 1–100 | — | Default 20. |
cursor | string | — | Cursor from a previous page. |
get_item
Read one item with its current valuation (consolidated), condition and categorisation. To wait for a valuation to finish, use wait_for_valuation instead of calling this repeatedly.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | The item to read. |
list_pictures · list_documents · list_attributes · list_questions
List respectively the pictures attached to an item, the documents attached to an item, the attributes the engine extracted or you submitted for an item, and the open and answered questions the engine asked about an item. Same parameters for all four:
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | The item to list. |
limit | integer 1–100 | — | Default 50. |
cursor | string | — | Cursor from a previous page. |
get_document_formats
List the MIME types add_document accepts for an item.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | The item to query. |
list_jobs
List analysis jobs, optionally for one item or status. Use it to see whether uploads have been processed.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | — | Restrict to one item. |
status | queued | running | completed | failed | cancelled | — | Filter by job status. |
limit | integer 1–100 | — | Default 20. |
cursor | string | — | Cursor from a previous page. |
get_job
Read one analysis job: its status and, if it failed, why.
| Parameter | Type | Required | Description |
|---|---|---|---|
job_id | uuid | yes | The job to read. |
Write tools 4
Rate-limited separately from reads and may cost the owner's ValuAid credits.
Every write requires a caller-provided idempotency_key, sent to the engine as the
Idempotency-Key header.
create_item
Create an empty draft, or supply an image (file from ChatGPT or image_base64) for a fast scan:
item, first picture and analysis job together. Next, add the user's other pictures and documents to this
item, then call wait_for_valuation.
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string ≤ 200 | — | Item title. |
consolidation_policy | debounced | explicit | — | Default debounced: consolidate shortly after the last input. explicit: only when the item is marked ready. |
file | object (OpenAI file) | one of file / image_base64, or neither | Picture file from ChatGPT — see File inputs. |
image_base64 | string (base64) | one of file / image_base64, or neither | JPEG, PNG or WebP, base64. |
filename | string ≤ 120 | — | Filename for the uploaded picture. |
idempotency_key | string | yes | See Idempotency. |
add_picture
Attach a picture (file from ChatGPT or image_base64) to an item. The engine analyses it and may
ask new questions. When all evidence is added, call wait_for_valuation.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | Target item. |
file | object (OpenAI file) | one of file / image_base64 | Picture file from ChatGPT. |
image_base64 | string (base64) | one of file / image_base64 | Base64 picture data. |
filename | string ≤ 120 | — | Filename for the picture. |
caption | string ≤ 500 | — | Picture caption. |
question_id | uuid | — | Answers a specific open question. |
idempotency_key | string | yes | See Idempotency. |
add_document
Attach a document (file from ChatGPT or data_base64) to an item. See
get_document_formats for accepted types. When all evidence is added, call
wait_for_valuation.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | Target item. |
file | object (OpenAI file) | one of file / data_base64 | Document file from ChatGPT. |
data_base64 | string (base64) | one of file / data_base64 | Base64-encoded document data. |
mime_type | MIME string | — | e.g. application/pdf. Optional if supplied via file; required when it cannot be resolved for a document. |
filename | string ≤ 120 | — | Filename for the document. |
label | string ≤ 200 | — | Document label. |
question_id | uuid | — | Answers a specific open question. |
idempotency_key | string | yes | See Idempotency. |
answer_question
Answer an engine question about an item with free text. Uses credits to re-analyse; then call wait_for_valuation.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | Target item. |
question_id | uuid | yes | The question to answer. |
text | string 1–8000 | yes | The answer, free text (MAX_TEXT_CHARS). |
idempotency_key | string | yes | See Idempotency. |
wait_for_valuation
Lets an agent deliver a result in one turn instead of telling the user to check back.
It re-reads the item every VALUATION_POLL_MS (3 s) for up to max_wait_seconds
(default and cap 50 s — kept under common 60 s client tool timeouts). Read-only: it only repeats GETs
the caller may already make.
| Parameter | Type | Required | Description |
|---|---|---|---|
item_id | uuid | yes | The item being valued. |
max_wait_seconds | integer 0–50 | — | Default 50. |
Outcome states
state | Meaning | What the agent should do |
|---|---|---|
completed | Valuation ready. | Present the valuation in item.consolidated. |
needs_answers | The engine has open questions (plus open_questions in the result). | Ask the user, reply with answer_question, then wait again. |
failed | Valuation failed (plus failure from the job's error). | Say so plainly; this connector cannot retry. Adding new evidence starts a new valuation. |
not_started | No evidence yet. | Add at least one picture or document, then wait again. |
in_progress | Still being valued. | Call wait_for_valuation again now with the same item_id; do not ask the user first. |
Every result carries a next_step hint. State is derived from the item:
consolidation.status of completed / failed / idle, or an
open-question count greater than zero (→ needs_answers); otherwise it keeps polling.
The flow the server instructs
Sent to clients at initialize as the MCP instructions:
create_itemwith the first picture (fast scan).- Add every other picture (
add_picture) and document (add_document) the user supplied, without asking. - Call
wait_for_valuation, and call it again for as long as it returnsin_progress. Do not stop to report that analysis is pending, and do not set up reminders or scheduled checks. - If it returns
needs_answers, ask the user those questions, send the replies withanswer_question, then wait again. - When it returns
completed, present the valuation. If it returnsfailed, say so plainly; this connector cannot retry it.
File inputs
Files can be sent as base64 (image_base64 / data_base64) or in
ChatGPT's file format (file parameter, with download_url and
file_id declared under _meta["openai/fileParams"]), capped by MAX_FILE_BYTES
(10 MiB decoded). Uploads accept exactly one file source; create_item also
allows a draft without a file. Empty file inputs are rejected.
Base64
- Standard or URL-safe alphabet, padded or unpadded; optional
data:…;base64,framing; whitespace ignored. - The decoded size must fit
MAX_FILE_BYTES— oversized files fail input validation with the size and the limit.
ChatGPT file objects
| Field | Type | Required |
|---|---|---|
download_url | string | yes — direct temporary URL to download the file |
file_id | string | yes — OpenAI file identifier |
file_name | string ≤ 120 | optional — original filename if known |
mime_type | MIME string | optional — MIME type if known |
SSRF-safe download of download_url
ChatGPT's file.download_url is caller-controlled, so the gateway fetches it only:
- over HTTPS on port 443,
- from an allowlisted host name (
FILE_DOWNLOAD_HOSTS;*.matches subdomains — no IP literals, no credentials in the URL), - that resolves to public addresses only,
- with redirects re-checked per hop (max 3).
Errors are generic; the host is logged, never the signed URL. Rejected hosts are logged as
file download rejected.
MIME resolution
Order: explicit mime_type → the file object's mime_type → the downloaded
content-type (unless application/octet-stream) → a filename extension map
(pdf, jpg, jpeg, png, webp, heic, heif, txt, csv, json) → application/octet-stream.
A document that ends up as application/octet-stream is rejected: mime_type is required.
Idempotency
- Every write requires a caller-provided
idempotency_key; the gateway never generates keys. Calls missing it fail input validation before reaching the engine. - Keys are 1–255 printable ASCII characters (U+0020–U+007E) after trimming outer whitespace.
- The key is sent to the engine as the
Idempotency-Keyheader. - Use a unique key for each intended write and reuse the same key and payload when retrying — the stored result is replayed, so a lost response can be retried safely.
Client migration: idempotency_key was added to create_item,
add_picture, add_document and answer_question. Older clients that do not
send it will get input-validation failures.
Rate limits & caps
| Limit | Scope | Default | Env |
|---|---|---|---|
| Tool calls | per user, per minute (all tools) | 60 | RATE_CALLS_PER_MINUTE |
| Writes | per user, per minute (the four credit-costing calls) | 20 | RATE_WRITES_PER_MINUTE |
| Failed sign-ins | per client IP, per minute, then 429 | 10 | RATE_AUTH_FAILURES_PER_MINUTE |
| File size (decoded) | per upload | 10 MiB | MAX_FILE_BYTES |
| Text length | per text input (e.g. answer_question.text) | 8 000 chars | MAX_TEXT_CHARS |
| HTTP body size | per request, enforced before JSON parsing | base64 size of the file cap + 64 KiB | MAX_BODY_BYTES |
| Upstream timeout | per engine call / file download | 60 000 ms | ENGINE_TIMEOUT_MS |
Rate counters are in-memory fixed windows — the gateway must run as a single replica. Across requests, only the rate counters, the bounded session-token exchange cache, the in-flight exchanges and the JWKS cache persist.
Configuration
All settings come from the environment; nothing is a secret.
| Variable | Default | Notes |
|---|---|---|
ENGINE_API_URL | https://api.engine.valuaid.app | Use the internal dokploy-network address when known. |
VALUAID_AUTH_URL | https://auth.valuaid.app | Auth server base (JWKS + token exchange). |
VALUAID_AUTH_ISSUER | https://auth.valuaid.app | Expected JWT issuer. |
VALUAID_AUTH_AUDIENCE | valuaid-api, https://mcp.connect.valuaid.app, https://mcp.connect.valuaid.app/mcp | Comma-separated values are added to the built-in defaults. |
MAX_FILE_BYTES | 10485760 (10 MiB) | Decoded file cap. |
MAX_BODY_BYTES | derived: base64 size of the file cap + 64 KiB | A deliberately smaller cap can restrict base64 uploads while still allowing larger downloaded files. |
MAX_TEXT_CHARS | 8000 | Max length of text inputs. |
RATE_CALLS_PER_MINUTE | 60 | Per user. |
RATE_WRITES_PER_MINUTE | 20 | Per user. |
RATE_AUTH_FAILURES_PER_MINUTE | 10 | Per client IP. |
ENGINE_TIMEOUT_MS | 60000 | Upstream timeout. |
PORT | 8080 | 1–65535. |
TRUST_PROXY | true | Uses X-Forwarded-For behind Traefik. Set false if exposed without a proxy. |
VALUATION_POLL_MS | 3000 | How often wait_for_valuation re-reads the item. |
FILE_DOWNLOAD_HOSTS | *.oaiusercontent.com,chatgpt.com,*.chatgpt.com,*.openai.com,*.blob.core.windows.net | Hosts ChatGPT file links may come from (*. = subdomains). |
Numeric settings must be positive safe decimal integers; PORT is 1–65535 and
TRUST_PROXY accepts only true or false. Upstream base URLs must be
HTTP(S) without credentials, query strings or fragments.
Connecting a client
The client sends Authorization: Bearer <token>. The easiest long-lived token is the
account's session token (the set-auth-token value from sign-in; valid for the domain's
session duration, revocable with POST /api/auth/revoke-session); a freshly minted 5-minute
JWT works too.
Claude Code
claude mcp add --transport http valuaid https://mcp.connect.valuaid.app \
--header "Authorization: Bearer $VALUAID_TOKEN"
Generic JSON config (Cursor, other clients)
{ "mcpServers": { "valuaid": { "type": "http", "url": "https://mcp.connect.valuaid.app",
"headers": { "Authorization": "Bearer ${VALUAID_TOKEN}" } } } }
OAuth clients (ChatGPT)
This gateway exposes RFC 9728 protected-resource metadata and authentication challenges. Login, client
registration, PKCE and token issuance are responsibilities of valuaid-auth; metadata alone does
not implement or verify that login flow. Header-based clients can send Bearer tokens directly.
Smoke test (no engine, no paid provider)
curl https://mcp.connect.valuaid.app/health should answer {"status":"ok"}.
An unauthenticated tool call must answer 401 with www-authenticate: Bearer:
curl -i -X POST https://mcp.connect.valuaid.app \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_items","arguments":{}}}'
Deployment
Runs on Bun in Docker behind Traefik (TLS termination); prepared for Dokploy. Key properties:
| Setting | Value |
|---|---|
| Project / environment | ValuAid Engine / production |
| Domain | mcp.connect.valuaid.app → service gateway, port 8080, HTTPS (Let's Encrypt), path / |
| DNS | Unproxied Cloudflare A record to the server's IP (same as api.engine.valuaid.app) |
| Network | External dokploy-network, stable alias valuaid-mcp-gateway |
| Replicas | Exactly one (rate-limit counters are in memory) |
| Container | Read-only, unprivileged bun user, healthcheck on /health, 256 MB / 0.5 CPU limits, size-capped JSON logs |
| Environment | None required — every variable has a default and the gateway holds no secrets |
- Behind the proxy,
TRUST_PROXY=trueis correct (per-IP sign-in limit viaX-Forwarded-For). Exposed without a proxy? Setfalse. ENGINE_API_URLdefaults to the public engine URL and works anywhere; an internal address (via adokploy-networkalias on the engine'sapiservice) can skip the public hop.
What the gateway does not do
- No extra authorization. It holds no service credential and adds no policy of its own — the engine and OpenFGA decide what the user may see or change.
- No billing logic. No wallet management, no usage events — credit reservation and settlement happen in the engine through ValuAid Meter. The gateway only adds rate limits.
- No destructive or admin surface. No delete, share, patch or job-retry tool exists.
- No key generation. Idempotency keys always come from the caller.
- No login flow. OAuth metadata and challenges only; sign-in, registration, PKCE and token issuance belong to
valuaid-auth. - No upstream detail leakage. Engine errors surface as short status-based tool errors; internal details and signed URLs are never exposed.