Skip to content

Commit 8f68eaa

Browse files
beardthelionguidovizosorenovate[bot]zopeVaibhavdavidmckayv
authored
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>
1 parent cbab27e commit 8f68eaa

10 files changed

Lines changed: 1279 additions & 28 deletions

‎CHANGELOG.md‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,46 @@ read that as a stolen token and revoke the whole connection. Every plugin call t
192192
token now locks the credential's vault row for the length of the exchange, so a second replica waits
193193
rather than races, and the rotated token is written back in the same transaction that held the lock.
194194
Nothing to configure; a connection just stops going stale under concurrent traffic.
195+
196+
### An MCP token is spent only by its own server, and only at the address it was given
197+
198+
Pointing a server at a credential is the one place this deployment takes a reference to a stored
199+
secret rather than the secret itself. Everywhere else, the value was typed into the same request that
200+
stores it: a Bot's key is minted from what an administrator pasted and the id it gets is nobody's to
201+
choose. So this is the one field where which secret and which address could be made to disagree, and
202+
the add settles the disagreement by spending the credential: the tool refresh runs before the call
203+
returns and sends what it decrypts to the URL from that same request.
204+
205+
Two ways they could disagree, and both are now refused. A server could be pointed at any `mcp`
206+
credential in the vault, including one minted for a different vendor, so a token given to one server
207+
was deliverable to another. And re-adding a server with a different URL rewrote the address while
208+
keeping the credential, so the same token could be sent somewhere else entirely with no
209+
cross-server trick at all: the token really did belong to that server, and only the address moved.
210+
211+
The second is why the first was not enough on its own. A credential now has to belong to the server
212+
it is attached to, and a server that already holds one cannot be re-added at a different address.
213+
Correcting a title or retrying an interrupted add sends the same URL and is unaffected. A server
214+
holding no credential can still be re-addressed, because there is nothing to misdirect. Moving a
215+
server that does hold one means removing it and adding it again with the token the new address is
216+
meant to have, which is the honest description of what has happened anyway.
217+
218+
This matters more than "an administrator could misconfigure something". A stored credential cannot
219+
be read back by anybody, by design: the credentials screen answers that a credential exists and
220+
never what it is. These two shapes were the way around that, so a deployment where somebody has
221+
used them should treat the credentials involved as disclosed and rotate them.
222+
223+
A token also stops outliving the server it was minted for. Re-adding a server without naming a
224+
credential used to clear the pointer while leaving the credential live, and removing a server retires
225+
its token by reading it off that pointer, so a cleared one meant the token survived its server and
226+
could be attached to a freshly created one at any address, where there was no longer a stored address
227+
to compare against. Three ordinary acts in a row and the binding above stopped meaning anything. The
228+
pointer now survives a re-add that names none, removal therefore finds and retires it, and a retired
229+
credential is refused rather than quietly attached to fail on its next call.
230+
231+
Curated servers keep working as they did. Their URL comes from the catalogue rather than the
232+
request, and a per-instance hostname is matched against the vendor's own anchored pattern before
233+
anything is stored, so re-adding one cannot point it at an address of the caller's choosing.
234+
195235
### Knowledge searches instead of guessing
196236

197237
A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
@@ -269,6 +309,34 @@ beside `metadata.google.internal`, and it carries a dot and none of the suffixes
269309
it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the
270310
`.internal` rule. Both are now named, so the address this check was written for is refused on purpose
271311
rather than by luck.
312+
### A curated MCP server is pointed at its own kind of credential too
313+
314+
Adding a server by URL was made to check which credential it is being pointed at. Adding one from the
315+
catalogue, the other half of the same screen, took the same field from the same request and stored it
316+
unread, so a credential of any kind could be attached to a curated server and spent by the refresh
317+
that runs before the add returns.
318+
319+
Worth being plain about the reach, because it is narrower than the path beside it. The column is a
320+
foreign key, so an id naming nothing was already refused by the database, and the one entry in the
321+
catalogue is reached with each person's own Google account, whose OAuth client is registered through
322+
its own call and sent to an address pinned in code. Nothing could be delivered to an address a caller
323+
chose. What was reachable was a credential of the wrong kind being accepted and spent on behalf of
324+
somebody who never agreed to it, and a malformed id arriving as a database error rather than as a
325+
refusal.
326+
327+
The rule now comes from the entry: a server the deployment holds one token for takes that token, and
328+
a server answered as the person asking takes no credential when it is added, because its client
329+
arrives through the call that mints it. Both add paths ask the same question in the same words, so a
330+
credential that does not exist and one of the wrong kind are still refused identically and the
331+
endpoint cannot be used to ask which ids are real. Adding a curated server the way the admin screen
332+
does is unchanged.
333+
334+
Adding a curated server that is already there no longer clears the credential it points at. The
335+
column holds the OAuth client that registering one put there, and re-adding the server to change an
336+
instance host said nothing about that client, but cleared it anyway: the credential row was left
337+
behind with nothing pointing at it and nothing to revoke it, and everybody who had connected their
338+
account was told the deployment has no client registered. A re-add that names no credential now
339+
leaves the one that is there alone.
272340

273341
### Name the private addresses an agent may live at
274342

‎server/src/plugins/catalogue.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,21 @@ const PATTERNS = new Map(
266266
]),
267267
);
268268

269+
/**
270+
* Which kind of credential this entry's server record may be pointed at, or null when it takes none
271+
* from the caller.
272+
*
273+
* Beside the entry rather than at the call site, because it is a property of the vendor's auth and
274+
* not of the request. `deployment-bearer` is the only kind that means "one token this deployment
275+
* holds for this server", which is what `mcp` names in the vault. A `user-oauth` server is answered
276+
* with the asker's own grant and its OAuth client is registered through its own call, which mints
277+
* the credential itself, so an id offered when the server is added is never the right one whatever
278+
* kind it names. A server needing no credential takes none.
279+
*/
280+
export function serverCredentialKind(entry: CatalogueEntry): "mcp" | null {
281+
return entry.auth.kind === "deployment-bearer" ? "mcp" : null;
282+
}
283+
269284
export function catalogueEntry(key: string): CatalogueEntry | null {
270285
return BY_KEY.get(key) ?? null;
271286
}

‎server/src/plugins/routes.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,12 @@ export function createPluginRoutes(
202202
});
203203
return context.json({ server });
204204
} catch (error) {
205-
if (error instanceof CatalogueEntryUnknownError) {
205+
// A refused credential is the administrator's mistake to correct, so it comes back as a
206+
// refusal with its reason rather than as a 500 the way an unmapped throw would.
207+
if (
208+
error instanceof CatalogueEntryUnknownError ||
209+
error instanceof CustomServerRefusedError
210+
) {
206211
return context.json({ error: error.message }, 400);
207212
}
208213
throw error;

‎server/src/plugins/store.ts‎

Lines changed: 170 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
classifyTool,
3333
customUrlRefusal,
3434
resolveServerUrl,
35+
serverCredentialKind,
3536
} from "./catalogue";
3637
import { McpServerError } from "./mcp";
3738
import { registerDynamicClient } from "./oauth";
@@ -1373,6 +1374,84 @@ export function createPluginStore(options: PluginStoreOptions) {
13731374
}
13741375
}
13751376

1377+
/**
1378+
* The credential a server is being pointed at is of the kind that server can spend.
1379+
*
1380+
* Both add paths dereference the pointer before they return, so this is checked where the pointer
1381+
* is accepted rather than where it is used. `mcp` is the only kind that answers "this server's own
1382+
* token". A `mcp_user_token` is one person's grant and a `mcp_oauth_client` identifies the
1383+
* deployment to a vendor; spending either here uses a credential on behalf of somebody who never
1384+
* agreed to it, which is the same objection `POST /api/admin/credentials` already makes when it
1385+
* refuses to mint those two by hand.
1386+
*
1387+
* The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a value
1388+
* that is not one makes the query itself fail rather than return no rows, and the caller gets a
1389+
* database error where a refusal belongs.
1390+
*
1391+
* One message for both "wrong kind" and "no such credential", deliberately. A caller who can tell
1392+
* those apart can ask this endpoint which credential ids are real.
1393+
*/
1394+
async function requireCredentialOfKind(
1395+
serverTitle: string,
1396+
serverId: string,
1397+
credentialId: string,
1398+
kind: "mcp" | null,
1399+
): Promise<void> {
1400+
/*
1401+
* A server that takes no credential when it is added is refused here rather than at the caller,
1402+
* so that offering an id is one question with one answer wherever it is asked. The wording says
1403+
* what is true of both kinds that reach it: a `user-oauth` server's client arrives through the
1404+
* call that mints it, and a server needing no credential has nothing to be given.
1405+
*/
1406+
if (!kind) {
1407+
throw new CustomServerRefusedError(
1408+
`${serverTitle} takes no credential when it is added.`,
1409+
);
1410+
}
1411+
1412+
const looksLikeId =
1413+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
1414+
credentialId,
1415+
);
1416+
/*
1417+
* Live, as well as the right kind and the right owner.
1418+
*
1419+
* A revoked credential cannot be decrypted, so attaching one only ever produced a server that
1420+
* fails on its next call. Refusing it here says so at the moment somebody can still act on it,
1421+
* and it closes the case where a token was retired precisely because it should stop being used.
1422+
*/
1423+
const [named] = looksLikeId
1424+
? await database
1425+
.select({
1426+
kind: credentialRows.kind,
1427+
provider: credentialRows.provider,
1428+
})
1429+
.from(credentialRows)
1430+
.where(
1431+
and(
1432+
eq(credentialRows.id, credentialId),
1433+
isNull(credentialRows.revokedAt),
1434+
),
1435+
)
1436+
: [];
1437+
1438+
/*
1439+
* Whose it is, as well as what it is.
1440+
*
1441+
* `provider` is the server a token was minted for: `storeMcpToken` sets it to the server id and
1442+
* is the only way the plugins screen makes one. Without this, any `mcp` row in the vault could
1443+
* be attached to any server, and since the refresh spends it against that server's address, a
1444+
* token given to one vendor was deliverable to another. Reading a credential back is otherwise
1445+
* impossible by design, so this closes the one field that accepts a reference to a secret rather
1446+
* than the secret itself.
1447+
*/
1448+
if (named?.kind !== kind || named.provider !== serverId) {
1449+
throw new CustomServerRefusedError(
1450+
"That is not a credential this server can use. Add the server's own token instead.",
1451+
);
1452+
}
1453+
}
1454+
13761455
async function requireServer(serverId: string) {
13771456
const [row] = await database
13781457
.select()
@@ -1411,21 +1490,52 @@ export function createPluginStore(options: PluginStoreOptions) {
14111490
const resolved = resolveServerUrl(input.key, input.instanceHost);
14121491
if (!resolved) throw new CatalogueEntryUnknownError(input.key);
14131492

1493+
/*
1494+
* The pointer is checked here for the same reason it is on the path below: the refresh that
1495+
* runs before this returns dereferences whatever it names.
1496+
*
1497+
* What that reaches is narrower on this path, because the URL is the catalogue's rather than
1498+
* the caller's, so a credential cannot be delivered to an address somebody chose. That is a
1499+
* property of today's catalogue rather than of this function: the one entry it holds is
1500+
* `user-oauth`, and the catalogue's own comment invites a fork to re-add the vendors that were
1501+
* taken out. The first `deployment-bearer` entry restores the full shape, so the check belongs
1502+
* here now rather than in the review that re-adds one.
1503+
*/
1504+
const credentialId = input.credentialId?.trim() || undefined;
1505+
if (credentialId) {
1506+
await requireCredentialOfKind(
1507+
resolved.entry.title,
1508+
resolved.entry.key,
1509+
credentialId,
1510+
serverCredentialKind(resolved.entry),
1511+
);
1512+
}
1513+
14141514
await database
14151515
.insert(mcpServers)
14161516
.values({
14171517
id: resolved.entry.key,
14181518
title: resolved.entry.title,
14191519
vendor: resolved.entry.vendor,
14201520
url: resolved.url,
1421-
credentialId: input.credentialId ?? null,
1521+
credentialId: credentialId ?? null,
14221522
addedBy: input.by,
14231523
})
14241524
.onConflictDoUpdate({
14251525
target: mcpServers.id,
14261526
set: {
14271527
url: resolved.url,
1428-
credentialId: input.credentialId ?? null,
1528+
/*
1529+
* Left alone when the caller sends none, rather than cleared.
1530+
*
1531+
* `registerOAuthClient` keeps the client it minted in this column, and adding the server
1532+
* again to change an instance host is not a statement about that client. Clearing it
1533+
* orphaned the credential row, which nothing then revokes, and told everybody who had
1534+
* connected that the deployment has no OAuth client registered. There is no longer a way
1535+
* to hand it back through this call either, since a `user-oauth` entry now refuses a
1536+
* credential id, so the pointer has to survive here.
1537+
*/
1538+
...(credentialId ? { credentialId } : {}),
14291539
addedBy: input.by,
14301540
updatedAt: new Date(),
14311541
},
@@ -1504,30 +1614,51 @@ export function createPluginStore(options: PluginStoreOptions) {
15041614
* One message for both "wrong kind" and "no such credential", deliberately. A caller who can
15051615
* tell those apart can ask this endpoint which credential ids are real.
15061616
*/
1617+
/*
1618+
* A credential is spent at the address it was given to, or not spent.
1619+
*
1620+
* Adding a server that is already here rewrites its URL, and the refresh that follows sends
1621+
* whatever credential it holds to the new one, in the same call. That is the same disclosure
1622+
* as naming another server's token and it needs no trick at all: the token really does belong
1623+
* to this server, and only the address moved. A check on whose credential it is cannot see it,
1624+
* which is why this rule is here and not folded into that one.
1625+
*
1626+
* Refused rather than repaired, because the two harmless readings of the request are both
1627+
* served by something else. Correcting a title or retrying an interrupted add sends the same
1628+
* URL and is unaffected, and genuinely moving a server means the vendor is at a new address,
1629+
* where the honest act is to remove it and add it again with the token that address is
1630+
* supposed to hold.
1631+
*
1632+
* Only this path. A curated server's URL comes from the catalogue rather than the request, so
1633+
* the most a caller can influence is an instance hostname, and that is matched against the
1634+
* vendor's own anchored pattern before anything is stored. Re-adding one cannot point it at an
1635+
* address of the caller's choosing, which is the whole of what this refuses.
1636+
*/
15071637
const credentialId = input.credentialId?.trim() || undefined;
1638+
const [existing] = await database
1639+
.select({ url: mcpServers.url, credentialId: mcpServers.credentialId })
1640+
.from(mcpServers)
1641+
.where(eq(mcpServers.id, input.id));
1642+
1643+
if (
1644+
existing &&
1645+
existing.url !== input.url &&
1646+
(existing.credentialId || credentialId)
1647+
) {
1648+
throw new CustomServerRefusedError(
1649+
`${input.id} is already here at a different address and holds a credential. Remove it and add it again, with the token the new address is meant to have.`,
1650+
);
1651+
}
1652+
15081653
if (credentialId) {
1509-
/*
1510-
* The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a
1511-
* value that is not one makes the query itself fail rather than return no rows, and the
1512-
* caller gets a database error where a refusal belongs. The same was true of the foreign key
1513-
* before this guard existed.
1514-
*/
1515-
const looksLikeId =
1516-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
1517-
credentialId,
1518-
);
1519-
const [named] = looksLikeId
1520-
? await database
1521-
.select({ kind: credentialRows.kind })
1522-
.from(credentialRows)
1523-
.where(eq(credentialRows.id, credentialId))
1524-
: [];
1525-
1526-
if (named?.kind !== "mcp") {
1527-
throw new CustomServerRefusedError(
1528-
"That is not a credential this server can use. Add the server's own token instead.",
1529-
);
1530-
}
1654+
// Always `mcp`: a server added by URL is reached with the one token the deployment holds for
1655+
// it, whatever the vendor is, because nothing here knows the vendor.
1656+
await requireCredentialOfKind(
1657+
input.title,
1658+
input.id,
1659+
credentialId,
1660+
"mcp",
1661+
);
15311662
}
15321663

15331664
await database
@@ -1546,7 +1677,21 @@ export function createPluginStore(options: PluginStoreOptions) {
15461677
set: {
15471678
title: input.title,
15481679
url: input.url,
1549-
credentialId: credentialId ?? null,
1680+
/*
1681+
* Kept when the caller names none, rather than cleared, for a reason beyond tidiness.
1682+
*
1683+
* Clearing it left the credential live with nothing pointing at it, and `removeServer`
1684+
* retires a token by reading it off the row: with the pointer gone it revoked nothing
1685+
* and deleted the server, so the token outlived the server it was minted for. It could
1686+
* then be attached to a freshly created server at any address, because the rule above
1687+
* compares against a row that no longer existed. Three ordinary acts, and the address
1688+
* this server was entrusted to stopped meaning anything.
1689+
*
1690+
* So the pointer survives, `removeServer` finds it, and a removed server's token is
1691+
* dead rather than loose. Detaching a token without removing the server is not a thing
1692+
* this endpoint does, and nothing asks it to.
1693+
*/
1694+
...(credentialId ? { credentialId } : {}),
15501695
addedBy: input.by,
15511696
updatedAt: new Date(),
15521697
},

0 commit comments

Comments
 (0)