ValuAid Connect MCP Gateway Documentation health check ↗

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

Endpointhttps://mcp.connect.valuaid.app (paths / or /mcp)
TransportStreamable HTTP, stateless, JSON responses — one POST per JSON-RPC message
AuthenticationAuthorization: Bearer — valuaid-auth JWT (RS256) or long-lived session token
Tools14 — 10 read, 4 write (writes are separately rate-limited and may cost credits)
OAuthRFC 9728 protected-resource metadata; the login flow itself lives in valuaid-auth
ImplementationServer name valuaid-mcp-gateway, v0.1.0 — Bun, TypeScript, @modelcontextprotocol/sdk, jose, zod

Architecture

The whole flow, one request at a time:

MCP client
Claude · Cursor · ChatGPT
HTTPS, Bearer →
POST /mcp
gateway
verify JWT (JWKS) · rate-limit · validate input
Bearer (same JWT) →
POST /v1/…
engine API
api.engine.valuaid.app
gateway
session token? exchange →
GET /api/auth/token
valuaid-auth
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

RouteMethodAuthBehaviour
/, /mcpPOSTper messageMCP streamable-HTTP endpoint
/healthGETnone{"status":"ok"}
/.well-known/oauth-protected-resource
/.well-known/oauth-protected-resource/mcp
GET, HEAD, OPTIONSnoneRFC 9728 metadata for OAuth clients (like ChatGPT)
anything else——404

Request rules

  • Content-Type: application/json is required on the MCP path; anything else gets 415.
  • 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; OPTIONS is 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

CaseResponse
Missing / bad token401 with WWW-Authenticate: Bearer realm="valuaid-mcp-gateway", resource_metadata="…/.well-known/oauth-protected-resource"
valuaid-auth unreachable or garbled502 “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

ParameterTypeMeaning
item_id, job_id, question_iduuidValidated as UUIDs before they ever enter a URL path.
limitinteger 1–100Page size; per-tool defaults noted below.
cursorstring ≤ 512Opaque cursor from a previous page's next_cursor.
idempotency_keystringRequired 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.

ParameterTypeRequiredDescription
qstring ≤ 200—Free-text search.
scopeaccessible | owned—accessible (default) or owned.
intake_statuscollecting | ready | consolidating—Filter by intake status.
limitinteger 1–100—Default 20.
cursorstring—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.

ParameterTypeRequiredDescription
item_iduuidyesThe 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:

ParameterTypeRequiredDescription
item_iduuidyesThe item to list.
limitinteger 1–100—Default 50.
cursorstring—Cursor from a previous page.

get_document_formats

List the MIME types add_document accepts for an item.

ParameterTypeRequiredDescription
item_iduuidyesThe item to query.

list_jobs

List analysis jobs, optionally for one item or status. Use it to see whether uploads have been processed.

ParameterTypeRequiredDescription
item_iduuid—Restrict to one item.
statusqueued | running | completed | failed | cancelled—Filter by job status.
limitinteger 1–100—Default 20.
cursorstring—Cursor from a previous page.

get_job

Read one analysis job: its status and, if it failed, why.

ParameterTypeRequiredDescription
job_iduuidyesThe 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.

ParameterTypeRequiredDescription
titlestring ≤ 200—Item title.
consolidation_policydebounced | explicit—Default debounced: consolidate shortly after the last input. explicit: only when the item is marked ready.
fileobject (OpenAI file)one of file / image_base64, or neitherPicture file from ChatGPT — see File inputs.
image_base64string (base64)one of file / image_base64, or neitherJPEG, PNG or WebP, base64.
filenamestring ≤ 120—Filename for the uploaded picture.
idempotency_keystringyesSee 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.

ParameterTypeRequiredDescription
item_iduuidyesTarget item.
fileobject (OpenAI file)one of file / image_base64Picture file from ChatGPT.
image_base64string (base64)one of file / image_base64Base64 picture data.
filenamestring ≤ 120—Filename for the picture.
captionstring ≤ 500—Picture caption.
question_iduuid—Answers a specific open question.
idempotency_keystringyesSee 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.

ParameterTypeRequiredDescription
item_iduuidyesTarget item.
fileobject (OpenAI file)one of file / data_base64Document file from ChatGPT.
data_base64string (base64)one of file / data_base64Base64-encoded document data.
mime_typeMIME string—e.g. application/pdf. Optional if supplied via file; required when it cannot be resolved for a document.
filenamestring ≤ 120—Filename for the document.
labelstring ≤ 200—Document label.
question_iduuid—Answers a specific open question.
idempotency_keystringyesSee Idempotency.

answer_question

Answer an engine question about an item with free text. Uses credits to re-analyse; then call wait_for_valuation.

ParameterTypeRequiredDescription
item_iduuidyesTarget item.
question_iduuidyesThe question to answer.
textstring 1–8000yesThe answer, free text (MAX_TEXT_CHARS).
idempotency_keystringyesSee 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.

ParameterTypeRequiredDescription
item_iduuidyesThe item being valued.
max_wait_secondsinteger 0–50—Default 50.

Outcome states

stateMeaningWhat the agent should do
completedValuation ready.Present the valuation in item.consolidated.
needs_answersThe engine has open questions (plus open_questions in the result).Ask the user, reply with answer_question, then wait again.
failedValuation failed (plus failure from the job's error).Say so plainly; this connector cannot retry. Adding new evidence starts a new valuation.
not_startedNo evidence yet.Add at least one picture or document, then wait again.
in_progressStill 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:

  1. create_item with the first picture (fast scan).
  2. Add every other picture (add_picture) and document (add_document) the user supplied, without asking.
  3. Call wait_for_valuation, and call it again for as long as it returns in_progress. Do not stop to report that analysis is pending, and do not set up reminders or scheduled checks.
  4. If it returns needs_answers, ask the user those questions, send the replies with answer_question, then wait again.
  5. When it returns completed, present the valuation. If it returns failed, 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

FieldTypeRequired
download_urlstringyes — direct temporary URL to download the file
file_idstringyes — OpenAI file identifier
file_namestring ≤ 120optional — original filename if known
mime_typeMIME stringoptional — 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-Key header.
  • 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

LimitScopeDefaultEnv
Tool callsper user, per minute (all tools)60RATE_CALLS_PER_MINUTE
Writesper user, per minute (the four credit-costing calls)20RATE_WRITES_PER_MINUTE
Failed sign-insper client IP, per minute, then 42910RATE_AUTH_FAILURES_PER_MINUTE
File size (decoded)per upload10 MiBMAX_FILE_BYTES
Text lengthper text input (e.g. answer_question.text)8 000 charsMAX_TEXT_CHARS
HTTP body sizeper request, enforced before JSON parsingbase64 size of the file cap + 64 KiBMAX_BODY_BYTES
Upstream timeoutper engine call / file download60 000 msENGINE_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.

VariableDefaultNotes
ENGINE_API_URLhttps://api.engine.valuaid.appUse the internal dokploy-network address when known.
VALUAID_AUTH_URLhttps://auth.valuaid.appAuth server base (JWKS + token exchange).
VALUAID_AUTH_ISSUERhttps://auth.valuaid.appExpected JWT issuer.
VALUAID_AUTH_AUDIENCEvaluaid-api, https://mcp.connect.valuaid.app, https://mcp.connect.valuaid.app/mcpComma-separated values are added to the built-in defaults.
MAX_FILE_BYTES10485760 (10 MiB)Decoded file cap.
MAX_BODY_BYTESderived: base64 size of the file cap + 64 KiBA deliberately smaller cap can restrict base64 uploads while still allowing larger downloaded files.
MAX_TEXT_CHARS8000Max length of text inputs.
RATE_CALLS_PER_MINUTE60Per user.
RATE_WRITES_PER_MINUTE20Per user.
RATE_AUTH_FAILURES_PER_MINUTE10Per client IP.
ENGINE_TIMEOUT_MS60000Upstream timeout.
PORT80801–65535.
TRUST_PROXYtrueUses X-Forwarded-For behind Traefik. Set false if exposed without a proxy.
VALUATION_POLL_MS3000How often wait_for_valuation re-reads the item.
FILE_DOWNLOAD_HOSTS*.oaiusercontent.com,chatgpt.com,*.chatgpt.com,*.openai.com,*.blob.core.windows.netHosts 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:

SettingValue
Project / environmentValuAid Engine / production
Domainmcp.connect.valuaid.app → service gateway, port 8080, HTTPS (Let's Encrypt), path /
DNSUnproxied Cloudflare A record to the server's IP (same as api.engine.valuaid.app)
NetworkExternal dokploy-network, stable alias valuaid-mcp-gateway
ReplicasExactly one (rate-limit counters are in memory)
ContainerRead-only, unprivileged bun user, healthcheck on /health, 256 MB / 0.5 CPU limits, size-capped JSON logs
EnvironmentNone required — every variable has a default and the gateway holds no secrets
  • Behind the proxy, TRUST_PROXY=true is correct (per-IP sign-in limit via X-Forwarded-For). Exposed without a proxy? Set false.
  • ENGINE_API_URL defaults to the public engine URL and works anywhere; an internal address (via a dokploy-network alias on the engine's api service) 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.