Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### An MCP token is spent only by its own server, and only at the address it was given

Pointing a server at a credential is the one place this deployment takes a reference to a stored
secret rather than the secret itself. Everywhere else, the value was typed into the same request that
stores it: a Bot's key is minted from what an administrator pasted and the id it gets is nobody's to
choose. So this is the one field where which secret and which address could be made to disagree, and
the add settles the disagreement by spending the credential: the tool refresh runs before the call
returns and sends what it decrypts to the URL from that same request.

Two ways they could disagree, and both are now refused. A server could be pointed at any `mcp`
credential in the vault, including one minted for a different vendor, so a token given to one server
was deliverable to another. And re-adding a server with a different URL rewrote the address while
keeping the credential, so the same token could be sent somewhere else entirely with no
cross-server trick at all: the token really did belong to that server, and only the address moved.

The second is why the first was not enough on its own. A credential now has to belong to the server
it is attached to, and a server that already holds one cannot be re-added at a different address.
Correcting a title or retrying an interrupted add sends the same URL and is unaffected. A server
holding no credential can still be re-addressed, because there is nothing to misdirect. Moving a
server that does hold one means removing it and adding it again with the token the new address is
meant to have, which is the honest description of what has happened anyway.

This matters more than "an administrator could misconfigure something". A stored credential cannot
be read back by anybody, by design: the credentials screen answers that a credential exists and
never what it is. These two shapes were the way around that, so a deployment where somebody has
used them should treat the credentials involved as disclosed and rotate them.

A token also stops outliving the server it was minted for. Re-adding a server without naming a
credential used to clear the pointer while leaving the credential live, and removing a server retires
its token by reading it off that pointer, so a cleared one meant the token survived its server and
could be attached to a freshly created one at any address, where there was no longer a stored address
to compare against. Three ordinary acts in a row and the binding above stopped meaning anything. The
pointer now survives a re-add that names none, removal therefore finds and retires it, and a retired
credential is refused rather than quietly attached to fail on its next call.

Curated servers keep working as they did. Their URL comes from the catalogue rather than the
request, and a per-instance hostname is matched against the vendor's own anchored pattern before
anything is stored, so re-adding one cannot point it at an address of the caller's choosing.

### Knowledge searches instead of guessing

A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
Expand Down Expand Up @@ -59,6 +98,34 @@ this port has to reach it another way**, which is what publishing it on every in
This does not reach back in time. A deployment that has been running with the two on one network
should assume a Bot could have read or written the database, and look at the trail with that in
mind.
### A curated MCP server is pointed at its own kind of credential too

Adding a server by URL was made to check which credential it is being pointed at. Adding one from the
catalogue, the other half of the same screen, 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.

Worth being plain about the reach, because it is narrower than the path beside it. 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 Google account, whose OAuth client is registered through
its own call and sent to an address pinned in code. Nothing could be delivered to an address a caller
chose. What was reachable was a credential of the wrong kind being accepted and spent on behalf of
somebody who never agreed to it, and a malformed id arriving as a database error rather than as a
refusal.

The rule now comes from the entry: a server the deployment holds one token for takes that token, and
a server answered as the person asking takes no credential when it is added, because its client
arrives through the call that mints it. Both add paths ask the same question in the same words, so a
credential that does not exist and one of the wrong kind are still refused identically and the
endpoint cannot be used to ask which ids are real. Adding a curated server the way the admin screen
does is unchanged.

Adding a curated server that is already there no longer clears the credential it points at. The
column holds the OAuth client that registering one put there, and re-adding the server to change an
instance host said nothing about that client, but cleared it anyway: the credential row was left
behind with nothing pointing at it and nothing to revoke it, and everybody who had connected their
account was told the deployment has no client registered. A re-add that names no credential now
leaves the one that is there alone.

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

Expand Down
15 changes: 15 additions & 0 deletions server/src/plugins/catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ const PATTERNS = new Map(
]),
);

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

export function catalogueEntry(key: string): CatalogueEntry | null {
return BY_KEY.get(key) ?? null;
}
Expand Down
7 changes: 6 additions & 1 deletion server/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,12 @@ export function createPluginRoutes(
});
return context.json({ server });
} catch (error) {
if (error instanceof CatalogueEntryUnknownError) {
// A refused credential is the administrator's mistake to correct, so it comes back as a
// refusal with its reason rather than as a 500 the way an unmapped throw would.
if (
error instanceof CatalogueEntryUnknownError ||
error instanceof CustomServerRefusedError
) {
return context.json({ error: error.message }, 400);
}
throw error;
Expand Down
195 changes: 170 additions & 25 deletions server/src/plugins/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
classifyTool,
customUrlRefusal,
resolveServerUrl,
serverCredentialKind,
} from "./catalogue";
import { McpServerError } from "./mcp";
import { transportFor } from "./transport";
Expand Down Expand Up @@ -560,6 +561,84 @@ export function createPluginStore(options: PluginStoreOptions) {
return { token: minted.accessToken };
}

/**
* The credential a server is being pointed at is of the kind that server can spend.
*
* Both add paths dereference the pointer before they return, so this is checked where the pointer
* is accepted rather than where it is used. `mcp` is the only kind that answers "this server's own
* token". A `mcp_user_token` is one person's grant and a `mcp_oauth_client` identifies the
* deployment to a vendor; spending either here uses a credential on behalf of somebody who never
* agreed to it, which is the same objection `POST /api/admin/credentials` already makes when it
* refuses to mint those two by hand.
*
* The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a value
* that is not one makes the query itself fail rather than return no rows, and the caller gets a
* database error where a refusal belongs.
*
* One message for both "wrong kind" and "no such credential", deliberately. A caller who can tell
* those apart can ask this endpoint which credential ids are real.
*/
async function requireCredentialOfKind(
serverTitle: string,
serverId: string,
credentialId: string,
kind: "mcp" | null,
): Promise<void> {
/*
* A server that takes no credential when it is added is refused here rather than at the caller,
* so that offering an id is one question with one answer wherever it is asked. The wording says
* what is true of both kinds that reach it: a `user-oauth` server's client arrives through the
* call that mints it, and a server needing no credential has nothing to be given.
*/
if (!kind) {
throw new CustomServerRefusedError(
`${serverTitle} takes no credential when it is added.`,
);
}

const looksLikeId =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
credentialId,
);
/*
* Live, as well as the right kind and the right owner.
*
* A revoked credential cannot be decrypted, so attaching one only ever produced a server that
* fails on its next call. Refusing it here says so at the moment somebody can still act on it,
* and it closes the case where a token was retired precisely because it should stop being used.
*/
const [named] = looksLikeId
? await database
.select({
kind: credentialRows.kind,
provider: credentialRows.provider,
})
.from(credentialRows)
.where(
and(
eq(credentialRows.id, credentialId),
isNull(credentialRows.revokedAt),
),
)
: [];

/*
* Whose it is, as well as what it is.
*
* `provider` is the server a token was minted for: `storeMcpToken` sets it to the server id and
* is the only way the plugins screen makes one. Without this, any `mcp` row in the vault could
* be attached to any server, and since the refresh spends it against that server's address, a
* token given to one vendor was deliverable to another. Reading a credential back is otherwise
* impossible by design, so this closes the one field that accepts a reference to a secret rather
* than the secret itself.
*/
if (named?.kind !== kind || named.provider !== serverId) {
throw new CustomServerRefusedError(
"That is not a credential this server can use. Add the server's own token instead.",
);
}
}

async function requireServer(serverId: string) {
const [row] = await database
.select()
Expand Down Expand Up @@ -598,21 +677,52 @@ export function createPluginStore(options: PluginStoreOptions) {
const resolved = resolveServerUrl(input.key, input.instanceHost);
if (!resolved) throw new CatalogueEntryUnknownError(input.key);

/*
* The pointer is checked here for the same reason it is on the path below: the refresh that
* runs before this returns dereferences whatever it names.
*
* What that reaches is narrower on this path, because the URL is the catalogue's rather than
* the caller's, so a credential cannot be delivered to an address somebody chose. That is a
* property of today's catalogue rather than of this function: the one entry it holds is
* `user-oauth`, and the catalogue's own comment invites a fork to re-add the vendors that were
* taken out. The first `deployment-bearer` entry restores the full shape, so the check belongs
* here now rather than in the review that re-adds one.
*/
const credentialId = input.credentialId?.trim() || undefined;
if (credentialId) {
await requireCredentialOfKind(
resolved.entry.title,
resolved.entry.key,
credentialId,
serverCredentialKind(resolved.entry),
);
}

await database
.insert(mcpServers)
.values({
id: resolved.entry.key,
title: resolved.entry.title,
vendor: resolved.entry.vendor,
url: resolved.url,
credentialId: input.credentialId ?? null,
credentialId: credentialId ?? null,
addedBy: input.by,
})
.onConflictDoUpdate({
target: mcpServers.id,
set: {
url: resolved.url,
credentialId: input.credentialId ?? null,
/*
* Left alone when the caller sends none, rather than cleared.
*
* `registerOAuthClient` keeps the client it minted in this column, and adding the server
* again to change an instance host is not a statement about that client. Clearing it
* orphaned the credential row, which nothing then revokes, and told everybody who had
* connected that the deployment has no OAuth client registered. There is no longer a way
* to hand it back through this call either, since a `user-oauth` entry now refuses a
* credential id, so the pointer has to survive here.
*/
...(credentialId ? { credentialId } : {}),
addedBy: input.by,
updatedAt: new Date(),
},
Expand Down Expand Up @@ -691,30 +801,51 @@ export function createPluginStore(options: PluginStoreOptions) {
* One message for both "wrong kind" and "no such credential", deliberately. A caller who can
* tell those apart can ask this endpoint which credential ids are real.
*/
/*
* A credential is spent at the address it was given to, or not spent.
*
* Adding a server that is already here rewrites its URL, and the refresh that follows sends
* whatever credential it holds to the new one, in the same call. That is the same disclosure
* as naming another server's token and it needs no trick at all: the token really does belong
* to this server, and only the address moved. A check on whose credential it is cannot see it,
* which is why this rule is here and not folded into that one.
*
* Refused rather than repaired, because the two harmless readings of the request are both
* served by something else. Correcting a title or retrying an interrupted add sends the same
* URL and is unaffected, and genuinely moving a server means the vendor is at a new address,
* where the honest act is to remove it and add it again with the token that address is
* supposed to hold.
*
* Only this path. A curated server's URL comes from the catalogue rather than the request, so
* the most a caller can influence is an instance hostname, and that is matched against the
* vendor's own anchored pattern before anything is stored. Re-adding one cannot point it at an
* address of the caller's choosing, which is the whole of what this refuses.
*/
const credentialId = input.credentialId?.trim() || undefined;
const [existing] = await database
.select({ url: mcpServers.url, credentialId: mcpServers.credentialId })
.from(mcpServers)
.where(eq(mcpServers.id, input.id));

if (
existing &&
existing.url !== input.url &&
(existing.credentialId || credentialId)
) {
throw new CustomServerRefusedError(
`${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.`,
);
}

if (credentialId) {
/*
* The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a
* value that is not one makes the query itself fail rather than return no rows, and the
* caller gets a database error where a refusal belongs. The same was true of the foreign key
* before this guard existed.
*/
const looksLikeId =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
credentialId,
);
const [named] = looksLikeId
? await database
.select({ kind: credentialRows.kind })
.from(credentialRows)
.where(eq(credentialRows.id, credentialId))
: [];

if (named?.kind !== "mcp") {
throw new CustomServerRefusedError(
"That is not a credential this server can use. Add the server's own token instead.",
);
}
// Always `mcp`: a server added by URL is reached with the one token the deployment holds for
// it, whatever the vendor is, because nothing here knows the vendor.
await requireCredentialOfKind(
input.title,
input.id,
credentialId,
"mcp",
);
}

await database
Expand All @@ -733,7 +864,21 @@ export function createPluginStore(options: PluginStoreOptions) {
set: {
title: input.title,
url: input.url,
credentialId: credentialId ?? null,
/*
* Kept when the caller names none, rather than cleared, for a reason beyond tidiness.
*
* Clearing it left the credential live with nothing pointing at it, and `removeServer`
* retires a token by reading it off the row: with the pointer gone it revoked nothing
* and deleted the server, so the token outlived the server it was minted for. It could
* then be attached to a freshly created server at any address, because the rule above
* compares against a row that no longer existed. Three ordinary acts, and the address
* this server was entrusted to stopped meaning anything.
*
* So the pointer survives, `removeServer` finds it, and a removed server's token is
* dead rather than loose. Detaching a token without removing the server is not a thing
* this endpoint does, and nothing asks it to.
*/
...(credentialId ? { credentialId } : {}),
addedBy: input.by,
updatedAt: new Date(),
},
Expand Down
Loading