SIGN IN SIGN UP

Spend an MCP token only for its own server, and only at its own address (#238)

* Point a curated MCP server only at a credential of its own kind

Adding a server by URL checks which credential it is being pointed at. Adding
one from the catalogue took the same field from the same request and stored it
unread, so a credential of any kind could be attached to a curated server and
spent by the refresh that runs before the add returns.

The reach is narrower than the path beside it and worth saying so. The column is
a foreign key, so an id naming nothing was already refused by the database, and
the one entry in the catalogue is reached with each person's own account, whose
OAuth client is registered through its own call and sent to a pinned address.
What was reachable is a credential of the wrong kind being accepted and spent on
behalf of somebody who never agreed to it, a malformed id arriving as a database
error where a refusal belongs, and the whole shape returning with the first
deployment-bearer entry a fork re-adds, which the catalogue invites.

Which kind an entry takes is decided beside the entry, because it is a property
of the vendor's auth rather than of the request. Both add paths then ask one
function the same question, so a credential that does not exist and one of the
wrong kind are still refused in the same words and the endpoint cannot be asked
which ids are real. The curated route maps that refusal to a 400 rather than
letting it surface as a 500.

Re-adding a curated server no longer clears the credential it points at. That
column holds the OAuth client registering one put there, and a re-add to change
an instance host said nothing about it while clearing it anyway, leaving the row
orphaned and everybody who had connected told there is no client registered.

* Spend an MCP token only for its own server, and only at its own address

Attaching a credential to a server is the one place this deployment accepts a
reference to a stored secret rather than the secret itself. Everywhere else the
value arrives in the request that stores it, and the id it gets is nobody's to
choose: storeAgentAuth mints its own row from the key an administrator typed. So
this is the field where which secret and which address can be made to disagree,
and the add is what settles it, because refreshTools runs before the call
returns and sends what it decrypts to the URL from the same request.

Both ways they could disagree are now refused. A credential has to belong to the
server it is attached to, which the vault already records: storeMcpToken sets
the provider to the server it mints for and is the only way the plugins screen
makes one, so nothing a deployment can reach through the UI is refused by this.
And a server that already holds a credential cannot be re-added at a different
address, which is the case a check on ownership cannot see: the token does
belong to that server, and only the address moved.

The second is why the first is not enough alone. Both delivered a stored token
to a host the caller named, before any Bot, grant or policy check existed, and a
stored credential is otherwise unreadable by design.

Refused rather than repaired, because both harmless readings are served by
something else. Correcting a title or retrying an interrupted add sends the same
URL and is untouched, a server holding no credential can still be re-addressed,
and moving one that does means removing it and adding it again with the token
the new address is meant to have.

Curated servers are unaffected: their URL comes from the catalogue rather than
the request, and an instance hostname is matched against the vendor's anchored
pattern before anything is stored.

The upsert test from #214 now mints its own token. It had reused one credential
across two server ids, which is a shape storeMcpToken cannot produce.

* Show a dot on a channel a Bot has spoken in unseen (#259)

* Give a membership a memory of when its channel was last read

* Stamp the caller's membership read, and say so in the roster

* Let a member say they have read a channel

* Carry the read marker to the app and let it be stamped

* Draw a dot on a channel a Bot has spoken in unseen

* Mark a channel read the moment it is the one on screen

* Narrow the admin and settings rails to the width their labels earn

* Keep a fast clock elsewhere from turning mark-read into a storm

* Say in the changelog what the unread dot is and is not

* Stamp a read against the message clock, not just this one

* Assert the clamped stamp without reaching through an optional chain

* Regenerate the read-marker migration behind the queue and the frames

* Refuse to mark a deleted channel read, matching the pin

* Update azure/setup-helm action to v5 (#260)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Say what a strict content-security-policy has to allow (#225)

* Point the test at the database the project actually has (#234)

* Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257)

* Refuse a credential written into the rest of the address (#230)

Refusing a credential in the userinfo closed one spelling and left the two
beside it open. A token in the query string or the fragment was accepted, and
addCustomServer writes the address it was given into mcp_servers.url and into
the configuration.changed audit payload verbatim. Redaction keys on the field
name and url is not a sensitive one, so the secret landed in an append-only row
in clear text, which is the disclosure the userinfo rule exists to prevent.

The name is read rather than matched against a list. An exact-name version of
this rule refused token and accepted auth_token, api_token, x-api-key and
X-Amz-Signature, and an operator has no way to know which spellings the check
happens to hold. Reading the name over-refuses in one direction on purpose: a
misread parameter costs a rename, a missed one cannot be deleted afterwards.
The fragment is split at the first question mark first, because a hash route or
an OAuth-style callback puts a path in front of the parameters and reading the
whole fragment as one query string turns all of it into a single name that
matches nothing.

metadata.goog is refused too, by asking the list browsing already uses rather
than keeping a second copy here. It is Google's own short alias for the metadata
server and it carries a dot and none of the suffixes this check lists, so it
read as an ordinary vendor name, while the long spelling was refused only
incidentally by the .internal test.

---------

Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
B
beardthelion committed
8f68eaa42bfb51f6a22247070bb997d25d988c1e
Parent: cbab27e
Committed by GitHub <noreply@github.com> on 8/26/2026, 3:21:27 PM