RavensPort
Give each AI agent its own MCP endpoint — pooling the servers you choose, exposing only the
tools you allow, with OAuth handled for you.
winget install RavensPort
winget
·
Microsoft Store
·
Releases
winget and the installer give you the full app. The Store build has no Proton Pass and no
mTLS — why.
A tray-resident Windows app that runs a local reverse proxy on 127.0.0.1. It owns the OAuth2
flow and token lifecycle for upstream APIs and MCP servers, then lets you compose those servers
into filtered, per-agent MCP endpoints.

> ### Upgrading from a version before 2.0?
>
> This version does not read your old configuration. Secrets have moved out of the encrypted
> store.dat file and into a vault in your password manager, and there is no import path.
> Credentials, upstreams, routes, MCP sources and funnels all need to be set up again, and every
> client needs to be handed the new key for the endpoint it calls.
>
> The old %AppData%\RavensPort\store.dat is erased the first time you start this version —
> every byte overwritten with zeros, then the file deleted. It is never read, and it is not kept
> for reference: it is a blob of your old credentials that nothing can open any more, so it is not
> left sitting on the disk. There is no prompt and no undo.
>
> You will need 1Password or Proton Pass installed and unlocked — or, for 1Password, a
> service account token, which needs nothing installed at all. The Microsoft Store build has no
> Proton Pass backend; see From the Microsoft Store. See
> Where your configuration lives.
The problem
MCP servers are increasingly behind OAuth2, but most MCP clients expect a bare HTTP endpoint with
no auth story. And once you have three servers connected, your agent sees all of their tools —
ninety of them — with no way to say "this agent gets these six."
RavensPort solves both halves:
| |
|---|
| Routes | Attach a live OAuth token to every request forwarded to an upstream. Your client never handles auth. |
| Funnels | Pool several MCP servers behind one local endpoint and expose only the tools, resources, and prompts you pick. |
The result: point each agent at http://127.0.0.1:5559/mcp/ and it sees exactly the
toolset you granted it, drawn from as many upstreams as you like — including ones it could never
reach on its own.
Features
- MCP Funnel — per-agent endpoints pooling multiple MCP servers with per-tool filtering
- Multi-provider OAuth2 — Google (via
Google.Apis.Auth), GitHub, Nextcloud, or any custom
OAuth2 provider (via IdentityModel.OidcClient; plain OAuth2, no OIDC discovery required)
- Device code sign-in — the RFC 8628 grant: the provider issues a short code, you enter it on
any device, and nothing has to come back to a redirect URI on this machine. For providers that
will not register a loopback callback, and for machines with no browser to open
- App logins — no browser, no user — the OAuth2 client credentials grant, and Google
service accounts (with optional domain-wide delegation). Both mint their own tokens from a
stored secret and re-mint them when they age out, so a route works from the moment it is saved
and keeps working with nobody at the keyboard
- Static API keys — for the many services that never offered OAuth; attach to routes exactly
like a token, with an optional Test button that checks the key against a real endpoint
- Flexible credential placement —
Authorization: Bearer by default, or any other
header, or a request-body field, with a custom value prefix. Query-string placements are not
offered: a secret in a URL is written to the upstream's access log and every intermediary's
- Any number of credentials per route — none, one, or several at once, in any mix of headers
and body fields; the same credential may appear in more than one place
- Automatic token refresh — 10 minutes ahead of expiry, in the background; app logins re-mint
from their stored secret instead of presenting a refresh token
- Any credential backs any route — not a fixed 1:1 mapping
- Stored in your password manager — 1Password or Proton Pass holds every secret; nothing is written to this PC (Proton Pass is not in the Microsoft Store build — why)
- 1Password without the desktop app — sign in with a service account token instead, so nothing
local has to be installed, running, or unlocked; the token is kept only in memory unless you ask
for it to be saved behind Windows Hello
- A proxy key per endpoint — every route and every funnel has its own, with its own expiry, so
other processes on your machine cannot spend your grants and a key leaked from one client cannot
reach the rest
- Client certificates (mTLS) — optionally require a certificate on every connection as well as
the key, so a process that reads a key out of a config file still cannot call the proxy
(not in the Microsoft Store build — why)
- Activity log with redaction and rotation, viewable in-app
- Tray-resident — starts hidden, survives provider and network errors, single-instance guard
- CI-published releases with build provenance attestation
Requirements
- Windows 10/11
- .NET 10 SDK or newer — only to build from
source; released binaries are self-contained
Install
With winget
winget install RavensPort
The full app — Proton Pass and mTLS included. Upgrades come with winget upgrade, and
winget uninstall RavensPort removes it.
This is the same Inno installer the Releases page carries, so everything in
From a release below applies to it: per-user, no elevation prompt, no .NET to
install. winget verifies the download against the SHA256 in the published manifest before running
it. The manifests live in packaging/winget/, versioned with the code that
built the installer they describe.
One thing worth knowing: RavensPort will not install over a copy of itself that is running, so
close it from the tray before winget upgrade. It fails cleanly rather than silently — winget
reports the package as in use and tells you to close it.
From a release
Download RavensPort-Setup-.exe from the Releases page and run it. It
installs per-user — no elevation prompt, no .NET install, no extraction — and leaves a Start menu
shortcut and an Add or Remove Programs entry behind.
Windows will warn about an unknown publisher — the installer is not Authenticode-signed. Instead
every release carries a build provenance attestation recording the workflow, commit, and
runner that produced it. Verify with the GitHub CLI:
gh attestation verify RavensPort-Setup-.exe --repo abishekvupputur/ravensPort
A pass means the file is byte-for-byte what CI built from this repository.
From the Microsoft Store
apps.microsoft.com/detail/9PBNQH53L61D
Installs and updates through the Store, and raises no unknown-publisher warning: Microsoft signs
the package at ingestion, which is the one thing the installer above cannot offer.
Two features are missing from it, because Store certification rejected them:
| winget / installer | Store |
|---|
| 1Password, single use | yes | yes |
| Routes, MCP funnels, OAuth2, proxy keys, activity log | yes | yes |
| Proton Pass as a vault backend | yes | no |
| mTLS and client certificate generation | yes | no |
Policy 10.1.5 read the setup page's Proton Pass card as promoting software acquired outside the
Store; 10.2.10 and 10.2.10.1 read Settings → Generate new certificate as certificate
installation. Neither is hidden in the Store build — the code behind them is not compiled into it.
Use winget install RavensPort if you need either. Both builds read the same vault, so you can
run the Store one on this machine and the full app on another; a Store install that finds mTLS
switched on serves plain http://127.0.0.1 and says so in the activity log, and every caller still
needs its endpoint's proxy key. A vault last written by the Proton Pass backend is simply not read
there.
See docs/STORE-MSIX.md for how the two builds are produced.
From source
clean-build.bat
Stops any running instance, wipes bin/obj, rebuilds, and launches. Look for the padlock in
the system tray — left-click opens the window, right-click gives a menu.
The proxy listens on http://127.0.0.1:5559 by default (changeable in Settings; requires a
restart).
Concepts
Four things, each built on the last:
Credential → an OAuth2 grant, an app login that signs in as itself, or a static API key
Upstream → a base URL to forward to
Route → a local path prefix that forwards to an upstream, attaching any number of credentials
Funnel → a local MCP endpoint pooling several MCP servers, filtered per agent
You need a credential and a route to reach a protected API. You need a funnel only if you want to
shape what an agent sees.
Setting up a credential
Pick a Credential type first — it decides the rest of the form.
| Type | For | Needs |
|---|
| OAuth2 (user login) | Google, GitHub, Nextcloud, any OAuth2 provider | client ID/secret, scopes, a browser consent flow |
| OAuth2 device code | the same providers, without registering a redirect URI | client ID, device + token endpoints, scopes, a code you type |
| API key | services that never offered OAuth | the key, and where it goes |
| OAuth2 client credentials | machine-to-machine APIs — the app is the user | client ID/secret, token endpoint, scopes |
| Google service account | Google Cloud and Workspace APIs, unattended | the downloaded JSON key file, scopes |
Every kind attaches to routes identically, and all of them share two fields:
- Default placement — where the secret goes by default. Used by the Test button, and it
prefills the entry when you attach this credential to a route (a route can still override it).
Every kind but API key defaults to
Authorization: Bearer ; API key defaults to
X-Api-Key: .
- Test endpoint (optional) — see Testing a credential.
The last two are app logins: nothing opens a browser and nobody consents. They mint their own
tokens from the stored secret, so a route can use one the moment it is saved, and re-mint when the
token ages out — there is no refresh token and nothing to reconnect. The row's Get token
button fetches one immediately, which is worth doing once so a mistyped secret is reported while
you are still looking at the form rather than as a 401 on the first real request.
API key
Credentials tab → set type to API key → name it → paste the key → Add credential.
- Set the placement to whatever the service documents —
X-Api-Key, PRIVATE-TOKEN,
?api_key=, Authorization with a token prefix, or a body field.
- The key is stored in your password manager with everything else and is never redisplayed. On
edit, a blank key box means "keep the current key", exactly as for a client secret.
- Keys containing control characters (a line break picked up when copying out of a wrapped email,
say) are rejected. Written into a header, a CR or LF ends the header line and lets the rest
be read as further headers — request splitting, aimed at your upstream. The forwarder refuses
such a value too, in case one reached the store some other way.
- There is nothing to connect, expire, or refresh, so an API-key credential shows Connect /
Disconnect / Refresh nowhere. Its status is simply whether a key is stored.
Google
- In Google Cloud Console, create an OAuth client.
- Desktop app is easiest — Google accepts any loopback port, nothing to register.
- For Web application, register the exact redirect URI shown in the Credentials tab
(
http://127.0.0.1:51004/authorize/).
- Paste Client ID/Secret, set scopes, Connect.
Every Google authorization forces the consent screen (prompt=consent) so a refresh token is
issued every time — otherwise the credential silently cannot auto-refresh later.

GitHub
- GitHub Settings → Developer settings → OAuth Apps → New OAuth App.
- Set Authorization callback URL to
http://127.0.0.1:51005/callback/ — GitHub matches it
exactly, so it has to be that, character for character.
- Pick the GitHub preset. Paste Client ID/Secret and set scopes using GitHub's own names
(
repo, read:org, gist) — they are not URLs.
An OAuth App token has no expiry and no refresh token; GitHub simply never ages it out, and the
credential shows Connected · no expiry. A GitHub App acting on behalf of a user, with expiring
tokens enabled, returns both and refreshes like any other provider.
Device code
The same user login as the browser flow, arranged so nothing has to come back to this machine.
The provider issues a short code, you enter it wherever you like, and RavensPort polls until you
have. Worth choosing when a provider will not accept a loopback callback URL, when you would
rather approve on a phone, or when the redirect keeps landing somewhere unhelpful.
- Enable it at the provider. GitHub: tick Enable Device Flow in the OAuth App's settings
— it is off by default and GitHub refuses the request without it. Google: the client must
be registered as TVs and Limited Input devices; a Desktop-app client is refused here.
- Credentials tab → type OAuth2 device code → pick a preset, which fills in both
endpoints → paste the Client ID → set scopes.
- Client secret is optional. RFC 8628 exists for clients that cannot hold one, and most
providers issue device codes to public clients. Leave it blank unless yours insists.
- Connect. The code appears in the status bar, is copied to your clipboard, and the
verification page opens in your browser — pre-filled with the code where the provider supports
it. Enter it anywhere; approving on a different device works exactly as well.
The device authorization endpoint is not the browser authorization endpoint. That one is a
page for a browser; this one is called by the app and answers JSON, and providers publish them at
different addresses. An OIDC provider lists it as device_authorization_endpoint in its discovery
document.
Once approved it is an ordinary grant with a refresh token, renewed in the background like any
other — the device flow is only how it was first approved.
Google service account
For Google APIs with nobody at the keyboard. The downloaded key file signs for its own access
tokens, so there is no consent screen and no refresh token.
- Google Cloud Console → IAM & Admin → Service Accounts → your account → Keys → Add key →
Create new key → JSON.
- Credentials tab → type Google service account → paste the whole file, unedited.
- Set scopes as full URLs (
https://www.googleapis.com/auth/drive.readonly). Scopes are
required: Google issues a token for a scopeless request without complaint and every API
then rejects it, which looks like a permissions problem rather than a configuration one.
- Impersonated user — leave blank for Google Cloud APIs, where the token should belong to the
service account itself. Set it to a person's address for Workspace APIs (Gmail, Calendar,
Drive), which act on that person's data. Grant the same scopes to the service account first
under Admin console → Security → API controls → Domain-wide delegation.
The key file is a secret in every sense — it holds a private key — so it lives in your password
manager like everything else and is never redisplayed. On edit, a blank box means "keep the
current key file".
OAuth2 client credentials
For APIs where the calling application is the principal — no user, no browser.
- Register a confidential client with your provider and note its token endpoint. There is no
authorization endpoint and no redirect URI here; nothing opens a browser.
- Credentials tab → type OAuth2 client credentials → paste Client ID/Secret, the token
endpoint, and any scopes.
- Extra token request parameters (optional) — written as
a=1&b=2. This is where the
parameter that decides what the token is actually for goes: audience=https://your-api/ for
Auth0, resource=… for Entra ID. Values are percent-decoded once, so paste them exactly as the
provider documents them.
- Send client credentials in request body — off by default, which sends them as an HTTP Basic
header (the OAuth2 default). Providers disagree about which they accept, and one that wants the
other answers a bare
invalid_client that says nothing about which half was wrong. If that is
what you are seeing for an ID and secret you know are right, try the opposite setting.
Nextcloud or custom OAuth2
- Create an OAuth2 client under Nextcloud Settings → Security → OAuth2, or your provider's
equivalent.
- Pick the Nextcloud or Custom preset. Fill in the Authorization and Token endpoints (or
an Authority for OIDC discovery), Client ID/Secret, and scopes.
- Register
http://127.0.0.1:51005/callback/ as the redirect URI if your provider requires
pre-registration. It is fixed and copyable from the UI.
Endpoints must be https, except on localhost — these fields receive your client secret and
refresh token.
Testing a credential
Set a Test endpoint — any URL that answers 200 to an authenticated GET — and the
credential's row gains a Test button. Clicking it sends one GET there with the credential
attached in its default placement, and reports what came back.
This matters most for API keys. An OAuth grant proves itself during the browser flow — a wrong
client secret cannot complete one — but nothing validates a pasted key, so without this the first
sign of a typo is a 401 on a real request hours later, which reads as an upstream problem
rather than a credential one.
| Result | Means |
|---|
200 | The credential works, in that placement, at that endpoint. |
401 / 403 | The secret, or where it is placed, is wrong. |
3xx | Almost certainly a redirect to a sign-in page. |
404 | Check the test endpoint URL itself, not the credential. |
| unreachable / timeout | Says nothing about the credential. |
- Only
200 passes, and redirects are not followed — following one would report a login
page as proof the credential works, which is exactly the failure being tested for.
- A body default placement cannot be tested: the request is a GET and has no body. Set the
default placement to a header to test, then override it on the route.
- The endpoint must be
https (or localhost) — the secret is sent there. Neither the result
message nor the activity log ever contains the secret or the query string it might sit in.
Setting up a route
Routes tab → add an Upstream (name + base URL) → enter a path prefix, pick the
upstream and (optionally) a credential → Add route.

- Strip prefix (on by default):
/app/my-service/foo forwards upstream as /foo — the prefix
is just a local label. Turn it off only if the upstream expects that prefix in its own path.
- The full local endpoint is shown ready to paste into a client config.
- Routes can be disabled without deleting them.
- Prefixes must be unique, and
/mcp is reserved for funnel endpoints.
- Upstream base URLs must be
https except on localhost — the access token goes to every request
forwarded there.
How the credential is sent
By default the token goes out as Authorization: Bearer . For upstreams that want it
elsewhere, each credential on a route has a placement, a name, and a value prefix:
| Placement | Name means | Result |
|---|
| Header (default) | header name | Authorization: Bearer or X-Api-Key: |
| Body | field in the request body | {"access_token": ""} |
> Query-string placements have been removed. A URL is recorded by the upstream's access
> log, by every intermediary between here and it, by browser history, and in the Referer of
> anything the response loads — none of which RavensPort can redact. An upstream that accepts its
> credential only as ?access_token= cannot be used through RavensPort.
>
> A route inherited from an earlier version with a query placement is not served at all: the
> config builder drops the whole route rather than forward it with that credential missing, and
> the activity log names it as SKIPPED. Change the entry to a header or body field to bring the
> route back.
- The value prefix is literal text before the token —
Bearer including the trailing space.
Leave it empty for a bare token.
- A caller-supplied header or field of the same name is replaced, never duplicated, so the
upstream never sees two candidate credentials.
- Body injection applies to JSON objects and
application/x-www-form-urlencoded bodies up to
1 MB, including chunked and streamed ones. Anything larger or in another content type is
forwarded untouched and the activity log says why.
- Header names the proxy owns are rejected:
Host, Content-Length, Transfer-Encoding,
Connection, Upgrade.
Zero, one, or several credentials per route
Select a route in the grid to open its editor — the route's own proxy key sits at the top, the
credentials below it. Add credential appends another
entry; Remove drops one. Every entry has its own credential, placement, name, and prefix, so a
route can carry any combination:
| Route attaches | Example |
|---|
| Nothing | plain forwarding hop to an upstream that needs no token |
| One credential | Authorization: Bearer — the usual case |
| Two or more headers | Authorization: Bearer <a> + X-Project-Key: <b> |
| Several headers | Authorization: Bearer <a> + X-Api-Key: <b> + PRIVATE-TOKEN: token <b> |
| Header + body | Authorization: Bearer <a> + {"auth_token": "<b>"} |
| Several body fields | {"access_token": "<a>", "project_token": "<b>"} — written in one rewrite |
| OAuth token + API key | Authorization: Bearer + X-Api-Key: — a user grant plus a project key, which plenty of APIs demand together |
- Entries are independent: two different credentials side by side, or the same credential
in two places (some APIs want the token in a header for auth and echoed in the body for audit).
OAuth2 and API-key credentials mix freely on one route.
- Adding a credential to a route prefills from that credential's default placement, so an
X-Api-Key credential arrives already described as one.
- A route with no credential still forwards, and still strips the caller's own
Authorization
header and cookies. Attaching nothing is not a licence to relay whatever the caller sent — that
guarantee holds on every route, and the route's own proxy key is still required.
- No two entries may write the same slot. Two credentials on one header or one body field
would silently overwrite each other, so the pair is refused at the point of editing. Header
names are compared case-insensitively (HTTP treats them that way); body field names are
case-sensitive.
- A credential you delete stops being attached on the routes that referenced it — the other
credentials on those routes keep working. The row shows
⚠ credential missing.
- If a request cannot carry a body placement (a
GET, or a body this cannot parse), that entry is
skipped and the header entries on the same route still arrive. The activity log names every
credential that was attached and every one that was not.
- Routes created by older versions carry their single credential over unchanged on first load.
MCP Funnel
A funnel is a local MCP endpoint at http://127.0.0.1:5559/mcp/ that pools several MCP
servers and exposes a subset of what they offer. Point one funnel at each agent.
Off by default — enable it with Enable MCP funnel on the MCP Funnel tab. While off, every path
under /mcp returns 404.
1. Add sources
A source is one MCP server the funnel can draw from:
| Kind | What it is |
|---|
| Route (credentialed) | An MCP server reached through one of your routes. The OAuth token is attached automatically. |
| URL (no auth) | Any MCP server needing no credential. |
Press Refresh on a source to connect and read what it offers. The status column reports the
result, or the reason it could not be reached.
2. Create a funnel
Give it a name and an endpoint slug. The full URL appears in the grid, selectable and ready to
paste.
3. Choose what it exposes
Select the funnel, tick the sources it pools, then per source and per kind (tools, resources,
prompts):
| Mode | Behaviour |
|---|
| All | Everything, including whatever the server gains later. |
| Include | Only what is ticked. A tool added upstream later stays hidden until you pick it. |
| Exclude | Everything except what is ticked. A tool added later is exposed immediately. |
Use Include to grant a known set, Exclude to revoke a few from an otherwise trusted
server.
Edits apply on the agent's next call — no reconnect, no restart.
Tool naming
Every name is prefixed with its source's alias: create_issue from a source aliased gh reaches
the agent as gh__create_issue. Resources are rewritten to funnel://gh/ and
mapped back on read.
Prefixing is unconditional by design. Prefixing only on collision would rename a tool the day you
add an unrelated source, breaking every agent prompt that referenced it.
Pointing an agent at a funnel
{
"servers": {
"my-agent": {
"url": "http://127.0.0.1:5559/mcp/my-agent",
"headers": { "X-Proxy-Key": "" }
}
}
}
Each funnel has its own proxy key — no route's key opens it, and no other funnel's does.
Select the funnel to copy its key.
The key must go in the X-Proxy-Key header. An MCP client that cannot set request headers cannot
reach a funnel — see The proxy key.
Behaviour
- Endpoints are independent. Two funnels drawing on the same upstream hold separate MCP
sessions, so one agent cannot perturb another and one expired session cannot take both down.
- Calls run in parallel, across endpoints and within one.
- A dead source degrades only itself — the healthy sources still list, and the failure is
shown on that source's row and in the log.
- Filtering is enforced on the call path, not just the listing. A tool an agent learned before
you unticked it is refused, and the call never reaches the upstream.
- Arguments are never logged. Tool names and outcomes are; the values an agent passes are not.
/mcp is reserved, and a request that already passed through a funnel is refused rather than
allowed to loop.
Limits
- Sources must be HTTP MCP servers. Local stdio servers (
npx …) are not supported.
- Sampling, elicitation, and resource subscriptions are not offered on a funnel endpoint — it runs
stateless, which is what makes edits take effect on the next call.
- Two agents on the same funnel share its upstream sessions. Give each agent its own funnel if
they must be isolated.
- A route-backed source that keys sessions on a cookie rather than the standard
Mcp-Session-Id header cannot hold a session: Cookie is stripped on the way upstream,
deliberately, so a caller cannot launder its own credentials through the proxy.
Calling the proxy
Every request — routes and funnels alike — must present the proxy key of the endpoint it is
calling. There is no key for the proxy as a whole: each route carries its own, each funnel
carries its own, and a key opens nothing but the endpoint it was issued for.
Copy a route's key from its row on the Routes tab (select the route to open its editor), and a
funnel's from the panel under the MCP Funnel tab.
curl -H "X-Proxy-Key: " http://127.0.0.1:5559/app/my-service/foo
The header is the only place the key is read from. A ?proxy_key= query parameter used to be
accepted as well, for clients that cannot set headers (browser EventSource, some MCP SSE
transports). It is now ignored: a URL ends up in the upstream's access log, in every intermediary's,
in browser history, and in the Referer of anything the response loads, and this key is the whole
of the proxy's authorization. A request carrying only the parameter gets 403, and the activity
log says which parameter it saw so the cause is not a guess. A client that cannot set a header
cannot use RavensPort.
The key is stripped before forwarding — the header, and the proxy_key parameter if a caller
still sends one — so it never reaches the upstream's access log or this app's activity log. Your
own headers and parameters pass through untouched.
Anything without a valid key gets 403: a wrong key, another endpoint's key, an expired key, and
a path belonging to no route or funnel all answer the same way, so the reply cannot be used to map
which endpoints exist.
The key can be backed by a client certificate as well — see
Client certificates (mTLS).
Key validity
Each key is generated when its route or funnel is created and is valid until you replace it
unless you say otherwise. Valid for on the row sets a lifetime — 1 or 4 hours, or 1, 7, 30, 90,
or 360 days — always measured from the moment the key was last generated, never from when you
picked it. Changing the setting therefore re-describes how long this secret was ever meant to live
rather than granting it more time: dropping a month-old key to "1 hour" ends it now. Once it lapses
the endpoint answers 403; the row says so in red, and so does the log.
Regenerate issues a new key for that one endpoint, immediately, and is the only thing that
restarts the clock — at whatever lifetime is currently selected. Clients still holding the old key
get 403; every other endpoint is untouched. It is also the way back from an expired key.
> Upgrading from a build with a single proxy-wide key: that key is no longer read. Every
> existing route and funnel is issued its own on first launch, so each client has to be given the
> key of the endpoint it calls.
Use Regenerate if a key is ever exposed; clients using the old key start getting
403 immediately.
Why the key exists, and why there is one per endpoint
Binding to 127.0.0.1 keeps other machines out, but it is not an authorization boundary:
every process on your computer, under any account, can reach loopback. Since the proxy attaches
your live OAuth token to whatever it forwards, an unguarded listener would hand your Google or
Nextcloud session to any local program that knew the port.
One key for the whole proxy made every client that held it a client of every route: an agent
given the key so it could reach a calendar endpoint could equally spend the grant attached to a
mail endpoint, and revoking one client meant re-keying all of them. Per-endpoint keys make the
blast radius of a leaked key exactly the endpoint it was issued for, and revocation a one-row
operation. It is also what makes a funnel meaningful — an agent handed a funnel's key sees the
tools that funnel exposes and cannot reach the routes underneath it directly.
The key also blocks DNS rebinding, where a page on an attacker's domain re-resolves that name
to 127.0.0.1 so the browser treats proxied responses as same-origin and lets its JavaScript read
them.
Alongside the key, the proxy refuses requests whose Host is not loopback, refuses requests
carrying an Origin header (only browsers send one), and strips Access-Control-* headers from
upstream responses so a permissive upstream cannot reopen the same hole.
Client certificates (mTLS) new in 4.2.0
> Not in the Microsoft Store build. Store certification rejected certificate generation under
> policies 10.2.10 and 10.2.10.1, so the Store package has no mTLS, no Client Certificate card, and
> no certificate-minting code. Install from a release if you need it — see
> docs/STORE-MSIX.md.
Optional, off by default. Turn on Require mTLS for all routes and funnels on the Settings tab
and the proxy switches from http://127.0.0.1:5559 to https://127.0.0.1:5559 and demands a
client certificate on every connection — routes, funnels, everything.
This is a second factor for the same door, not a replacement for the proxy key. A key sits in
whatever config file the client reads it from, so any process that can read that file can spend it;
a certificate has to be installed as well, and both are checked. Every request still needs the key
of the endpoint it is calling.
Changing the setting requires a full restart of RavensPort. The listener's scheme and its
certificate demand are fixed when Kestrel binds, so nothing about this takes effect until the app
is restarted — the Settings tab says so in red until it is.
Generate the certificate first. The switch refuses to turn on until one exists. RavensPort
never mints a certificate behind the switch, because generating one means choosing the PFX password
and a password the app picked for you is a password every install shares. Use Generate new
certificate below, choose your own, then come back and enable mTLS.
Generating and exporting
Generate new certificate asks for a password, then mints a self-signed certificate. RavensPort
keeps it in the vault with everything else and presents it at both ends: the listener serves it and
demands it back, and the funnel presents it when it dials this app's own routes.
- You choose the password, and it is shown nowhere afterwards — not on the status line, not in
the log. Write it down before confirming. There is no way to recover it; the way out of a
forgotten one is generating another certificate and reinstalling it everywhere.
- Export certificate asks where to save the
.pfx. That file is the credential — whoever
holds it can call the proxy — so put it where the client that needs it can read it, and nowhere
else. The password stops Windows and curl refusing a password-less PFX; it does not make a copy
of the file safe to leave lying around.
- Generating a new certificate invalidates the old one immediately. Every client holding the
previous file is refused. Export the new one, install it everywhere it is used, and restart.
Pointing a client at it
The certificate is self-signed, so nothing trusts it until you say so. Install it as a trust anchor
on the machine that will be calling the proxy, and every client there verifies the listener the
ordinary way from then on.
Two things have to be true before a call succeeds, and they are easy to conflate because one file
carries both. Your client must trust the certificate RavensPort serves (steps 1–2), and it must
present that certificate back (step 3). Getting only the first right produces a handshake that
fails after the trust check passes, which is a confusing place to land.
Please execute the following powershell commands from the location where cert file (RavensPort_ClientCert.pfx) was downloaded.
1. Get the public half out of the export. The .pfx carries the private key; a trust store only
needs the certificate, and only the certificate should travel.
$cert = Get-PfxCertificate -FilePath RavensPort_ClientCert.pfx # prompts for the password
Export-Certificate -Cert $cert -FilePath ravensport.cer
certutil -encode ravensport.cer ravensport.pem # PEM, for clients that want one
2. Install it into the Windows trust store.
# This user only — no administrator rights needed
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\CurrentUser\Root
# Machine-wide, for services and other accounts — run as Administrator
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\LocalMachine\Root
Windows asks you to confirm, and it is worth reading: a root you install is one that machine will
trust wherever it is presented. Take it back out when the certificate is rotated or retired, or the
machine goes on trusting a certificate you no longer control.
Get-ChildItem Cert:\CurrentUser\Root |
Where-Object { $_.Subject -eq 'CN=RavensPort MCP Client' } |
Remove-Item
3. Install the client certificate where the caller can find it. Step 2 taught the machine to
trust RavensPort; this is the other half of the handshake — the certificate your client presents
back. Import the .pfx into your personal store:
$client = Import-PfxCertificate `
-FilePath RavensPort_ClientCert.pfx `
-CertStoreLocation Cert:\CurrentUser\My `
-Password (Read-Host 'PFX password' -AsSecureString)
$client.Thumbprint
Keep that thumbprint. It is how you name this certificate later, and the name matters: every
certificate RavensPort mints carries the same CN=RavensPort MCP Client, and these certificates can be indistinguishable by subject alone.
4. Call the proxy. In PowerShell, curl is an alias for Invoke-WebRequest and \ is not a
line continuation — write curl.exe and use a backtick, or the command silently becomes something
else entirely.
curl.exe --cert "CurrentUser\MY\" `
-H "X-Proxy-Key: " `
https://127.0.0.1:5559/app/my-service/foo
The certificate is named by store reference, not by file, and on Windows that is the only form
that works. The curl.exe in System32 is built against Schannel, and Schannel cannot load a
client certificate from a .pfx on disk: --cert cert.pfx: --cert-type P12 fails the
handshake with schannel: AcquireCredentialsHandle failed: SEC_E_UNKNOWN_CREDENTIALS, which reads
like a bad password and is not one. That P12 form belongs to an OpenSSL-backed curl — WSL, most
Linux distributions — where the file and password are passed directly and no import is needed:
curl --cert cert.pfx: --cert-type P12 \
--cacert ravensport.pem \
-H "X-Proxy-Key: " \
https://127.0.0.1:5559/app/my-service/foo
Run curl --version if you are unsure which you have; the backend is named on the first line.
PowerShell can also make the call natively, taking the .pfx directly and needing no import:
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 `
((Resolve-Path RavensPort_ClientCert.pfx).Path, '', 'PersistKeySet')
Invoke-WebRequest -Uri https://127.0.0.1:5559/app/my-service/foo `
-Certificate $cert `
-Headers @{ 'X-Proxy-Key' = '' } `
-UseBasicParsing
PersistKeySet is load-bearing: without it the private key can be discarded after loading, and the
handshake then fails on a certificate that appeared to read back perfectly.
Neither form disables verification — no --insecure on the curl line, no -SkipCertificateCheck
on the PowerShell one — and neither should. Step 2 is what makes ordinary verification succeed;
reaching for those flags instead is the failure described below.
Node needs both halves handed to it explicitly, since it carries its own OpenSSL and reads nothing
from the Windows stores — steps 2 and 3 do nothing for it:
const https = require('node:https');
const fs = require('node:fs');
const path = require('node:path');
// Resolved against this file, not the working directory: a bare 'cert.pfx' is looked up
// wherever node happened to be started from, which is rarely where the file is.
const here = (name) => path.join(__dirname, name);
https.request({
host: '127.0.0.1',
port: 5559,
path: '/app/my-service/foo',
headers: { 'X-Proxy-Key': '' },
pfx: fs.readFileSync(here('RavensPort_ClientCert.pfx')),
passphrase: '',
ca: fs.readFileSync(here('ravensport.pem')),
rejectUnauthorized: true
}, (res) => {
// socket.authorized is the answer to "did verification actually happen", which the status
// code does not tell you.
console.log(res.statusCode, res.socket.authorized);
}).end();
ca: replaces Node's trust store rather than adding to it, so this agent verifies RavensPort
and nothing else. That is the right trade for a client that only ever calls the proxy; a process
that also talks to ordinary HTTPS hosts wants NODE_EXTRA_CA_CERTS=ravensport.pem instead, which
appends.
What each failure looks like
Four ways to run that snippet, and what each one returns. The point of the table is the middle two:
they are what proves the first row means anything.
| Variant | Result |
|---|
| As written above | HTTP 200, socket.authorized === true |
ca: removed | DEPTH_ZERO_SELF_SIGNED_CERT — self-signed certificate |
pfx:/passphrase: removed | ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN, SSL alert 46 |
Wrong passphrase: | Error: mac verify failure, thrown synchronously |
DEPTH_ZERO_SELF_SIGNED_CERT means the trust anchor was doing real work. RavensPort's
certificate is its own root and Node trusts no root it was not given, so dropping ca: fails
rather than quietly succeeding — which is what tells you rejectUnauthorized: true is not
decorative here.
- SSL alert 46 is
certificate_unknown, sent by RavensPort, and it is the reply to a client
that presented no certificate. It arrives as a TLS alert rather than an HTTP status because the
handshake never completed — the same reason an expired certificate shows up as a dropped
connection with nothing to read. Seeing it means mTLS is genuinely enforced on the listener, not
merely offered.
mac verify failure is thrown by https.request itself, before a socket is opened, when the
PFX cannot be decrypted. It is a throw, not an 'error' event, so a req.on('error', …)
handler never sees it and an otherwise careful script dies with an unhandled exception. Wrap the
call in try/catch if a wrong password is a case you want to report.
> Testing these variants: pass agent: false. Node's global agent reuses keep-alive sockets and
> its cache key does not cover passphrase, so a failing variant run after a successful one can
> ride the already-authenticated connection and return HTTP 200. A test that cannot fail is worse
> than no test.
rejectUnauthorized: true is the most important line in that snippet. What it prevents is worth
being precise about.
Loopback is not a trust boundary. 127.0.0.1:5559 is first come, first served: any process running
as any user on the machine can bind it whenever RavensPort is not already holding it — before the
app starts at boot, after it exits, in the window a crash leaves open. Binding a loopback port needs
no privilege and no consent, and nothing about the address makes whatever answers on it the proxy.
Then look at the order things happen in. The TLS handshake completes first. The request — method,
path, and every header, X-Proxy-Key and anything else your client attaches included — goes out
after it. Verifying the server is the only step that happens before your client has said
anything. A client that skips it has already handed the endpoint's proxy key to whatever answered by
the time there is any way to notice, and that key does not come back: it spends the OAuth grant
behind that route, and it works against the real proxy afterwards.
The client certificate does not cover this. mTLS is mutual, and the two halves protect different
parties — the certificate your client presents proves the client to RavensPort, and the
certificate RavensPort presents is what proves the server to your client. An impostor on 5559 can
demand a client certificate exactly as RavensPort does; asking costs it nothing, and your client
will present one. The trust anchor from step 2 is the only thing that tells the two listeners apart.
RavensPort itself refuses to start when the port is already taken and says so on the setup page, so
a squatter is visible from the app's side. Your client has no such view. It connects, something
answers, and it either checks the certificate or it does not — and that check is the whole of what
stands between a local process and your credentials.
Three things decide whether that works:
- Use
127.0.0.1 or localhost, and nothing else. The certificate carries exactly those names
— localhost, 127.0.0.1, ::1 — and checking the hostname is part of what verification does.
Any other name for the same machine is refused: Node says ERR_TLS_CERT_ALTNAME_INVALID.
- curl on Windows reads the Windows trust store. Both the
curl.exe in System32 and the one
Git Bash ships are built against Schannel, so for this half step 2 is all they need and no
--cacert belongs on the command line. A curl built against OpenSSL — WSL, most Linux
distributions — reads its own CA bundle instead: pass --cacert ravensport.pem there rather than
installing a root. Being Schannel is also why those two need step 3 and the store reference; see
step 4.
- Node does not read the Windows stores at all, whatever steps 2 and 3 did, because it carries
its own OpenSSL. Both halves have to be handed to it in the options object the way the snippet
does —
ca: for the trust anchor, pfx: for the certificate it presents. NODE_EXTRA_CA_CERTS
covers the first for a whole process; there is no environment variable for the second.
On RavensPort's side of the same handshake: it compares the thumbprint of what it was handed against
its own and refuses anything else, it enforces the certificate's expiry date itself, and every request still needs the proxy key of the endpoint it is calling.
Expiry
Certificates are minted with a 90-day life. Nothing renews them, and there is no CA behind
them — no revocation list to publish, no way to recall a copy that leaked — so the expiry date is
the only thing that retires one.
It is enforced, at both ends. Past the date the proxy refuses the certificate it issued, including
its own funnel's hop into its own routes. An expired certificate fails during the TLS handshake,
so clients see a dropped connection rather than a status code — there is no 403 to read, which
is why the date is worth watching.
The Settings tab shows when the current certificate expires, and says so in red once it is within
14 days. Rotating means generating, exporting, installing on every client, and restarting, so it
is not something to start on the day it stops working.
Settings and diagnostics

Autostart — Settings tab → Start with Windows. Writes an HKCU\...\Run entry pointing at
the current exe. Never set automatically.
Credentials — Connect (or Get token, for an app login, which opens no browser), Refresh,
Disconnect (clears the local token without revoking the grant at the provider), Test, Edit,
Delete. A colored dot and expiry time refresh every 15 seconds. The token buttons are hidden for
an API key, which has nothing to authorize and nothing to refresh; Disconnect is hidden for an app
login, which would simply mint another token on the next request. Test appears only once a test
endpoint is set.

Where your configuration lives
Everything — OAuth client secrets, access and refresh tokens, API keys, per-endpoint proxy keys,
routes, upstreams, MCP sources and funnels, and settings — is stored in a vault called
RavensPort in your password manager. None of it is kept on this PC. There is no local
cache and no fallback file, so the proxy does not start until the vault is reachable — 1Password or
Proton Pass unlocked, or a 1Password service account token entered, which needs nothing local
unlocked at all. (RavensPort does write logs, and — if you sign in to Proton Pass from inside the
app, or ask it to remember a 1Password service account token — its own encrypted credential for
that sign-in. Neither contains any of the above. See Logs.)
Supported managers
| Manager | Client | Install |
|---|
| 1Password | Native SDK (embedded), or op.exe when a service account token is used and the CLI is installed | winget install AgileBits.1Password (desktop app required for that mode) — or a service account token, which needs nothing installed |
| Proton Passnot in the Store build | pass-cli | winget install Proton.PassCLI |
RavensPort installs neither of them, and does not link to anywhere that would. It looks for the CLI,
shows the command above if it is missing, and runs what you installed.
Open RavensPort and it walks you through the rest: sign in and set up a vault. It only ever touches
items it created, so the vault stays safe to keep other things in.

Signing in — 1Password. There are two ways in, picked on the setup card. Which one you want is
a decision about the machine, so RavensPort asks rather than guessing.
1. Desktop app integration. In the 1Password desktop app, navigate to Settings → Developer
and enable the 1Password SDK, then enter your
account name — the exact name at the top of the 1Password sidebar, such as Personal.

When RavensPort first tries to access your vault, 1Password will show a consent screen:

This mode needs 1Password running and unlocked, and it carries a known defect on 1Password's side
(ipc-client#9): if 1Password starts
while RavensPort is already running, it never opens its integration channel, silently, for the life
of that 1Password process. Restarting 1Password alone does not fix it — quit both, start 1Password,
then RavensPort. RavensPort now avoids causing this itself (see below), but it cannot repair a
1Password restarted mid-session, and says so plainly instead of leaving you to guess.
2. Service account token. new in 4.3.0 Create a
1Password service account, grant it access
to the RavensPort vault explicitly — a service account cannot see your Private vault, and
without the grant it sees no vaults at all — and paste its token on the setup card. Nothing local
has to be running, unlocked, or even installed, and none of the desktop-app defect above applies.
> A service account token is a bearer credential. Whoever holds the string is the service
> account, from any machine, until you rotate it — scoping the vault limits what it opens, not who
> can use it. Never keep it in plain text, never enter it on a PC you do not own, never share it.
By default the token is written nowhere: it lives in memory for the run and is asked for again
after a restart, so an install set to start at login serves nothing until someone enters it. Tick
Keep this token on this PC, behind Windows Hello and it is stored in Windows Credential Manager
encrypted with a key derived from a Hello signature — never in plain text, and only a gesture on
this PC brings it back. That has its own consent screen, and its own credential separate from the
Proton Pass session, so Forget saved token cannot sign you out of Proton Pass. The offer is not
made where Windows Hello is unavailable — there is no plain-text fallback and there must not be one.
Once a token is saved, the card offers Use the saved token and Forget saved token; service
accounts rotate, and a revoked one would otherwise fail every startup with nothing in the UI to
clear it. Disconnect always drops the in-memory token, but never the saved one — that is what
Forget saved token is for.
Where the real op.exe is installed and its signature verifies, the token is passed through it
instead of the in-process SDK, so the credential lives in a child process that exits rather than in
a library mapped into RavensPort for the rest of the run. No CLI, or one that cannot be verified,
simply uses the SDK — the token needs no CLI at all.
If both are installed and neither vault clearly holds the configuration, RavensPort asks which
to use — every launch. The choice is the one thing that cannot live in the vault, and this app
deliberately stores nothing about itself locally. Once one vault has a configuration in it, that
one is used and the question stops.
Settings → Password manager shows which manager and which vault are in use, where its CLI is
and what version answered, and whether everything on screen has reached the vault:
| Button | Does |
|---|
| Sync now | Pushes pending changes. With nothing pending it re-reads the vault instead, which is what catches an item you deleted in the password manager |
| Rewrite all items to vault | Writes every item and the config item again from memory — the way back from a vault edited by hand. It replaces every item, so prefer the integrity check when only one is missing |
| Re-initialise from vault | Throws away everything in memory and loads it again. Asks first: every route and funnel is rebuilt, so requests in flight fail, and anything unsaved is lost |
| Disconnect | Stops using the manager and empties the configuration. Asks first, for the same reason |
| Vault integrity | Compares vault against configuration — see below |
Nothing in the vault is deleted by disconnecting, so connecting the same vault brings it all
back. Connecting a different one gives you a separate set of credentials, routes and funnels:
one install, as many profiles as you have vaults, one at a time. After disconnecting, the setup
page lists the account's vaults to pick from — RavensPort deliberately stops rediscovering the one
you just left, or it would reattach to it before you could choose.
If two vaults both hold a configuration, RavensPort will not guess: opening one would overwrite
the other on its next save. The setup page names them and asks. To switch profiles at any time, pick
another vault — or create one — on the setup page; both are offered even when a vault is already
connected.
Vault integrity accounts for every live item in the vault and changes nothing until you pick:
- Items nothing refers to — left by a delete that failed or a save that died part way, a second
item claiming a record that already has one, or an item titled as RavensPort's in a shape it can
no longer match (a record id edited away — no save will ever touch that item again). Delete one at
a time or all at once.
- Records whose item is missing — each says what it costs (a credential's secret is then only in
memory, and dies with the process). Write missing items to vault puts them back from memory
and touches nothing else; removing the record from the configuration is the other, destructive
option. Write them while RavensPort is still running — the secret exists nowhere else.
- Everything else in this vault — your own items, listed but never read, written, or deleted by
RavensPort. They are shown so the check covers the whole vault rather than only what this app can
recognise, and because a renamed RavensPort item shows up nowhere else. Delete is one at a time,
never part of a bulk action.
Saving deliberately sees less than checking does: it only looks at items titled as RavensPort's,
which is what keeps your own entries out of reach of its housekeeping.
> Items your password manager considers deleted are ignored everywhere — Proton Pass keeps
> returning trashed items from item list, and 1Password returns archived ones. Reading those made
> an emptied vault look full and a deleted credential look present.
What the vault looks like
| Item | Holds |
|---|
RavensPort Config | Routes, upstreams, MCP sources and funnels, settings — the topology, with no secrets in it |
RavensPort credential — [] | One per credential: client id and secret, API key, service account key file, access and refresh tokens |
RavensPort route key — [] | One per route: its proxy key |
RavensPort funnel key — /mcp/ [] | One per funnel: its proxy key |
Secrets get their own items so your password manager can conceal them, show them, and let you copy
one out without reading JSON. Each field lives on exactly one side — a credential's scopes are in
the config item and nowhere else, its secret is in its own item and nowhere else — so there is
never a question of which copy is right.
You can edit these in your password manager. RavensPort picks up changes on its next load and
overwrites them on its next save, so use Reload from vault after editing by hand.
If you delete a credential's item there, RavensPort takes that as the credential being gone: on
the next load it removes it from the configuration, tells you in a banner (naming any routes that
now forward unauthenticated), and writes the corrected config item back. Without that it kept a
credential the vault no longer had, and every launch raised the same ghost. A credential that never
had an item — a public OAuth client with no secret — is left alone; the removal only happens when
the config item points at an item that has been deleted. Sync now on the Settings tab does the
same check on demand when there is nothing waiting to be saved.
While the vault is locked
Everything keeps working. Edits, OAuth token refreshes, and proxy-key rotation all go ahead
against the in-memory configuration, and RavensPort writes them to the vault as soon as your
password manager is reachable again. A locked manager never takes a route down and never blocks
the UI.
A banner appears while anything is unsaved, with an I've unlocked it — save now button. The
sync also retries on its own, so unlocking is usually enough.
If you decline an authorization prompt, that is taken as an answer: retrying is what raises the
prompt again, so RavensPort stops asking until you press I've unlocked it — save now. Nothing is
lost by declining — the pending changes stay in memory and go up on the next save. And a 1Password
that locks, or a prompt dismissed, no longer costs you the connection: the SDK invalidates its client
id in both cases, so RavensPort rebuilds the connection and replays the call once, rather than
failing every later call for the life of the process.
The catch, stated plainly. Nothing is written to disk while it waits — a pending change lives
in memory and nowhere else, because a spill file would be a copy of your secrets sitting outside
your password manager, which is the thing this app exists to avoid. So:
> If RavensPort exits while changes are still unsaved, those changes are gone. Any credential
> whose token was refreshed in that window has to be reconnected, because the refresh token in the
> vault is the one the provider has already replaced.
Choosing Exit from the tray while anything is pending makes one last attempt to save and then
warns you before quitting, so this should never happen by accident. A machine shutdown or a crash
gives no such warning.
This is a deliberate trade. The alternative — refusing to save or refresh until the vault is
reachable — breaks every OAuth route the moment its access token ages out, which happens far more
often than exiting mid-lock. Only the newest token is ever useful, so there is nothing worth
keeping that a reconnect cannot restore.
Keeping it available. The option that weakens nothing is a token — a 1Password service account
token entered on the setup card, or a Proton Pass personal access token in
PROTON_PASS_PERSONAL_ACCESS_TOKEN (read-only), scoped to the vault in use — so nothing has to stay
unlocked at all. Running unattended on the Settings tab explains both, deliberately away from the
lock banner: that banner interrupts you mid-task and should offer the thirty-second fix, not a
walkthrough of creating a long-lived credential. Failing that, you can raise the auto-lock timeout in your
manager's security settings, but that is a real trade: the timeout exists to limit how long an
unattended machine holds your secrets decrypted. RavensPort never changes those settings for you.
Using one vault from two machines
RavensPort assumes it is the only thing writing to RavensPort. Both managers sync, so two
installs pointed at one vault will overwrite each other's changes — last writer wins, with no
warning. Each save stamps the machine name and a revision into the config item, so you can at least
tell after the fact. Run it on one machine at a time.
Logs
No configuration is written to disk. What RavensPort does write lives under %AppData%\RavensPort\
and %LocalAppData%\RavensPort\:
| Path | Contents |
|---|
%AppData%\...\logs\activity-YYYYMMDD.log | Proxied requests and responses, connects, refreshes, route reloads, vault operations. Rotates every 2 days, auto-deletes after ~10 |
%AppData%\...\logs\errors.log | Unhandled exceptions and provider errors with stack traces |
%LocalAppData%\...\pass-session\ | RavensPort's encrypted Proton Pass session, if you signed in from the app. Unreadable without the session key, which is never written down |
%LocalAppData%\...\cli\pass-cli\ | A Proton Pass CLI downloaded by a version before 4.3.1. Still used if it is there; nothing writes here any more |
The Settings tab can open either log, open the folder, or prune old ones.
Redaction. Activity logs record request paths and query parameter names; values are
redacted, and tokens are never logged. Vault operations log the command, exit code, and duration —
never the output, which for a read is the item contents. Control characters are escaped so one
event can only ever produce one line: request paths reach the log percent-decoded, so without this
a caller could write fabricated entries.
Startup warnings. Any stored upstream or token endpoint using plain http off-machine is
flagged as STARTUP WARNING. New entries are rejected when added, but the vault can also be edited
directly in your password manager, which bypasses that check.
Building
dotnet build RavensPort.slnx -m:1
-m:1 (no parallel MSBuild) avoids an intermittent WPF markup-compile race on a freshly cleaned
obj/ that produces spurious CS2001/MC1000 errors. clean-build.bat retries once for the
same reason.
Tests
dotnet test tests/RavensPort.Core.Tests/RavensPort.Core.Tests.csproj
877 tests, covering the OAuth and storage layers, the full HTTP method × credential placement
matrix against a real upstream, multi-credential routes (several headers, header + body together,
the same credential in two slots at once, and routes attaching nothing), static API keys (forwarded
in every permitted placement, mixed with an OAuth token on one request, and a key with a line break
refused before it reaches the wire), credential testing against a real endpoint that checks what it
was sent, the refusal of query-string credentials and of a proxy key sent in a URL, the wipe of the
pre-2.0 store, and end-to-end funnel behaviour — including that two funnels over one upstream stay
isolated, run in parallel, and never cross-deliver a response.
Publishing a standalone exe
dotnet publish src/RavensPort.App/RavensPort.App.csproj -p:PublishProfile=win-x64-selfcontained -c Release
Produces a self-contained RavensPort.exe (~180 MB, runtime bundled) under
src/RavensPort.App/bin/Release/net10.0-windows/publish/win-x64/. See
THIRD-PARTY-NOTICES.md before redistributing — it bundles components
whose licenses require their notices travel along.
Building the Store variant
dotnet build RavensPort.slnx -p:StoreBuild=true -m:1
Defines STORE_BUILD, which drops Proton Pass and mTLS — see
BuildProfile and
docs/STORE-MSIX.md. It must be a command-line property, not a publish-profile
one: profile properties do not cross a ProjectReference, so setting it there would build
RavensPort.App with the features removed and RavensPort.Core with them still in. The packaging
scripts check the built product name and refuse the wrong build in either direction.
Every other command on this page builds the full app, unchanged.
Project layout
src/RavensPort.Core/ OAuth flows, password-manager storage, YARP proxy config, MCP funnel,
activity log — no WPF dependency, just the engine
src/RavensPort.App/ WPF tray app: hosts Kestrel + YARP in-process, tray icon, UI
tests/RavensPort.Core.Tests/ xunit tests for Core
RavensPort.App owns the process. It starts the Kestrel/YARP host on a thread-pool task rather
than the WPF dispatcher thread — avoiding a sync-over-async deadlock — then initializes the tray
icon. The proxy and the UI share one DI container.
Releases
Pushing a version tag (v*) runs the test suite and, only on success, builds and publishes a
release with a provenance attestation. Nothing is released off a failing build.
The installer is the only asset. The bare self-contained exe used to ship beside it, but running it
installed nothing — no Start menu entry, no way back in after the tray menu's Exit — which is what
Microsoft Store certification rejected. Building from source still produces that exe if you want it.
Troubleshooting
A funnel source shows an error after Refresh. The message is the upstream's. A route-backed
source also needs its route to exist and be enabled.
A funnel exposes no tools. Check the source's Tools mode — under Include with nothing
ticked, nothing is exposed. Press Refresh on the source first to populate the list.
An upstream returns 200 but the client reports no reply. The activity log annotates non-JSON
responses, e.g. <- 200 [text/html] for POST /app/foo. That usually means the upstream served a
sign-in or landing page instead of running its handler — check its deployment settings and
whether it accepts your token.
Requests get 403. The endpoint's proxy key is missing, wrong, expired, or was regenerated —
or the key belongs to a different route or funnel, which opens nothing here. Copy the key from
the row of the endpoint you are calling: the Routes tab for a route, the MCP Funnel tab for a
funnel. The activity log names which endpoint refused and why.
Connections are dropped with no status code at all, since enabling mTLS. The failure is in the
TLS handshake, which is over before any HTTP exists to answer with. Either the client is presenting
no certificate or the wrong one, it is still calling http:// at a listener that now answers
https://, or the certificate has expired — the Settings tab shows the date, and the activity log
names which of these it was. What the client reports depends on its TLS stack; the common ones are
listed under What each failure looks like.
schannel: AcquireCredentialsHandle failed: SEC_E_UNKNOWN_CREDENTIALS from curl. Not a wrong
password, whatever it sounds like. Windows' own curl.exe is built against Schannel, which cannot
load a client certificate from a .pfx file: name it by store reference instead. See
Pointing a client at it, steps 3 and 4.
mac verify failure from Node. The PFX password is wrong. Thrown by https.request before any
connection is attempted, so an 'error' handler does not catch it.
DEPTH_ZERO_SELF_SIGNED_CERT from Node. The trust anchor never reached the client: no ca: in
the options and no NODE_EXTRA_CA_CERTS. Installing the root into Windows does nothing for Node.
A path that used to work now 403s instead of 404ing. A request to a path belonging to no route
and no funnel has no key to check against and is refused rather than answered, so which prefixes
exist cannot be discovered by watching status codes.
A route 502s. The activity log records YARP's reason. Confirm the upstream base URL is
reachable and https.
An upstream returns 401 and you cannot tell which credential it objected to. A 401 does not
say, so all of the route's credentials are flagged. Set a Test endpoint on each and use the
Test button to narrow it down — that reports per-credential, which a proxied request cannot.
1Password stops answering, and restarting 1Password does not help. Its integration channel is
only opened at 1Password startup, and it is not opened at all if another process holds
op_sdk_ipc_client.dll at that moment — a defect on 1Password's side
(ipc-client#9). Quit both, start
1Password, then RavensPort. RavensPort no longer touches that library while 1Password is closed, so
the start-at-login case cannot happen; a 1Password restarted mid-session still requires the order
above, and RavensPort says so instead of failing silently. A service account token avoids the whole
problem — it never loads that library.
1Password says the CLI "is not signed at all", but it plainly is. WinGet installs op.exe as a
symlink in its Links directory, and that is the copy on PATH. A symlink is a zero-byte reparse
point carrying no signature of its own, so the trust check was inspecting an empty file. RavensPort
now resolves the link and verifies the binary behind it. A link that cannot be followed is reported
as exactly that — temporary, and not an accusation that the vendor binary was tampered with — and
service-account mode falls back to the in-process SDK rather than failing the connection.
RavensPort keeps raising 1Password prompts every few seconds. Fixed in 4.3.0: a declined
authorization was retried on a timer, and reaching the vault is what raises the prompt. A decline
now stops the retries until you press I've unlocked it — save now.
An API key looks right but is always rejected. Check the placement, not the key: a valid key
in the wrong header is as broken as a wrong one, and Test reports both as 401. Also check for a
stray line break — a key with one is refused before it reaches the wire, and the activity log
says NOT ATTACHED for that entry.
A client credentials grant answers invalid_client for an ID and secret you know are right.
Providers disagree about where the pair belongs, and the one that wants the other place says only
this. Toggle Send client credentials in request body and try again — the activity log records
which placement each attempt used.
A service account gets a token, but every API call returns 403. Almost always the scopes.
Google issues a token for a scopeless or wrongly-scoped assertion without objecting, and the
refusal only arrives at the API. If you set an Impersonated user, the same scopes must also be
granted to the service account's client ID under Admin console → Security → API controls →
Domain-wide delegation — without that, Google answers unauthorized_client, which the activity
log records verbatim.
A GitHub credential shows "Connected · no expiry". That is correct, not a missing value. An
OAuth App token has no expires_in and no refresh token, and GitHub does not age it out.
A device code request is refused with unauthorized_client. The flow is not switched on for
that client. GitHub needs Enable Device Flow ticked in the OAuth App's settings; Google issues
device codes only to a client registered as TVs and Limited Input devices. Neither is the
default, and the client ID is otherwise perfectly valid — which is why the error names the client.
The device code was never approved and the flow gave up. Codes expire, usually in 10-15
minutes; the status bar shows the deadline while it waits. Press Connect again for a fresh one.
Polling stops on its own at that point — it does not keep hitting the provider.
License
MIT — see LICENSE. Third-party dependencies (all MIT or Apache-2.0) are listed in
THIRD-PARTY-NOTICES.md, which also covers what redistributing the
published exe requires.