Skip to content
Merged
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
33 changes: 26 additions & 7 deletions src/mgmt/tools/createApiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ import { MGMT_ADDITIVE } from "./annotations.js";
// point when the account has one) is rendered in ONE place, shared with
// mgmt_reveal_api_key, so a field cannot be surfaced on one path and dropped on
// the other. That is precisely how enterpriseApiKeys went missing.
import { describeEndpointToken } from "./endpointToken.js";
import {
describeEndpointToken,
ENDPOINT_TOKEN_DISCLOSURE,
ENDPOINT_TOKEN_DISCLOSURE_LINES,
} from "./endpointToken.js";

/**
* SHARK-3513 — the human-facing description of a key creation.
Expand Down Expand Up @@ -82,8 +86,14 @@ export function registerCreateApiKey({
"Create or get a dedicated per-project API key (JWT) for this " +
"account, optionally restricted to a set of blockchains. " +
"STATE-CHANGING. Idempotent by index: an existing index returns the " +
"existing key. The secret key material is never returned in the tool " +
"output." +
"existing key. " +
// SHARK-3620: this slot used to read "The secret key material is never
// returned in the tool output", which the reply falsified on every
// successful call — it carries the endpoint token, and a ready URL with
// the token in it. The approval page has always been accurate, so the
// description now IS the approval page's sentence rather than a second
// wording of it.
ENDPOINT_TOKEN_DISCLOSURE +
TOTP_DESCRIPTION_SUFFIX +
HITL_DESCRIPTION_SUFFIX,
inputSchema: z
Expand Down Expand Up @@ -167,10 +177,14 @@ export function registerCreateApiKey({
// credential-bearing reply has to be told that is what they are
// approving, so the two are named separately: the signed material
// stays hidden, the usable credential does not.
"The key's signed material (jwt_data) is never shown to the " +
"assistant. The reply DOES carry the key's endpoint token, and " +
"the account's enterprise API keys where it has any, which are " +
"live credentials that land in the conversation transcript.",
//
// SHARK-3620: and the tool DESCRIPTION now renders this same
// constant, because it used to assert the opposite. The wording is
// unchanged here; what changed is that there is only one of it, and
// that it arrives as TWO effect lines — as one 201-character line it
// was clipped by the page's 200-char per-effect bound, exactly at
// the clause naming the transcript.
...ENDPOINT_TOKEN_DISCLOSURE_LINES,
],
account: await accountAddressForDisplay(gateway),
}),
Expand Down Expand Up @@ -262,6 +276,11 @@ export function registerCreateApiKey({
const resolved = await describeEndpointToken({
key: created,
worker: deps.worker,
// SHARK-3619: this is the create path, so the key may be seconds old
// and invisible to the RPC proxy for about a minute. Reveal renders
// the same surface without this caveat, because its key already
// exists.
justCreated: true,
});
return {
content: [
Expand Down
100 changes: 98 additions & 2 deletions src/mgmt/tools/endpointToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,102 @@ function enterpriseSurface(resolved: WorkerTokenResult): string {
* create and reveal must not be able to disagree about it.
*/
const DATA_CALL_HANDOFF =
"\n\nTO MAKE DATA CALLS WITH IT. The URL above works immediately from any " +
"HTTP client, and that is the shortest path to a first call. The Ankr data " +
"\n\nTO MAKE DATA CALLS WITH IT. The URL above needs no session setup at " +
// SHARK-3619: this used to read "works immediately", which was a claim about
// TIME and was false for a key that had just been minted — the proxy answers
// -32050 for about a minute afterwards. What the sentence is actually for is
// the claim about SETUP: no session, no header, no client. That half is true
// on both paths and is what makes this the shortest route to a first call, so
// it is what survives. The timing caveat belongs to the create path alone and
// lives in NEW_KEY_PROPAGATION_NOTE.
"all: any HTTP client can call it, and that is the shortest path to a " +
"first call. The Ankr data " +
"MCP server is different: it binds ONE API key per session, at connect time, " +
"so a session that is already open keeps the key it was opened with and " +
"cannot be repointed at this one. To reach this key from the data tools, set " +
"it as that server's `x-ankr-api-key` header (or its Bearer token) and open a " +
"NEW session, which in most clients means reconnecting that server. This key " +
"stays valid meanwhile, so nothing has to be created again.";

/**
* SHARK-3620 — ONE sentence about what a key-bearing reply discloses, shared by
* the tool DESCRIPTION and the human approval PAGE.
*
* THE DEFECT IT CLOSES. mgmt_create_api_key's description said "The secret key
* material is never returned in the tool output" while the reply carried the
* endpoint token in full, plus a ready-to-call URL with the token embedded. The
* approval page for the same call was already accurate, and that is the point:
* the two disagreed about whether a live credential lands in the model
* transcript, which is precisely the property a reader checks before deciding
* whether a tool is safe to call in a shared or logged session.
*
* WHY A CONSTANT RATHER THAN TWO CAREFUL WORDINGS (see the LINES form below for
* why there are two of them). Two wordings that agree today
* are two wordings that can drift, and the drift is invisible because each side
* reads fine on its own. Sharing the sentence makes agreement structural: there
* is nothing to keep in sync. The pairing is asserted by identity in
* test/mgmt-key-lifecycle-truthfulness.ts, not by a pair of regexes.
*
* The DISTINCTION is the load-bearing part. There are two secrets here and only
* one of them is withheld, so a summary like "this returns credentials" would be
* true and useless. jwt_data is the input to the exchange and never leaves the
* server; the endpoint token is the result, and it is live.
*/
/**
* TWO LINES, AND THE SPLIT IS LOad-BEARING — found while wiring the pairing
* test. The approval page stores each effect through `clip(e, 200)`, and this
* sentence was 201 characters, so the page a human reads to decide whether a
* credential lands in their transcript was cut at "…which are live credentials…"
* and never reached the words that say where they land. It is the only truncated
* effect on the whole gated surface (test/mgmt-gated-display.test.ts now pins
* that for every call site), and it was the one that mattered most.
*
* Splitting rather than shortening keeps the distinction intact: line one is the
* secret that stays hidden, line two is the credential that does not.
*/
export const ENDPOINT_TOKEN_DISCLOSURE_LINES = [
"The key's signed material (jwt_data) is never shown to the assistant.",
"The reply DOES carry the key's endpoint token, and the account's " +
"enterprise API keys where it has any, which are live credentials that " +
"land in the conversation transcript.",
];

/** The same disclosure as one sentence, for the tool description. */
export const ENDPOINT_TOKEN_DISCLOSURE =
ENDPOINT_TOKEN_DISCLOSURE_LINES.join(" ");

/**
* SHARK-3619 — the create path's timing caveat, in the reply that creates the
* expectation.
*
* MEASURED, NOT ESTIMATED. On 2026-08-07 a key created through this server
* answered -32050 on rpc.ankr.com and became callable between 60 and 90 seconds
* later; the transition itself fell inside a 10-second poll window. The number
* carries its date so that a future edit has to change both, the same discipline
* the allowlist writes' 45-100 second window follows.
*
* WHY THE VERDICT IS SPELLED OUT rather than left to the reader. The failure
* this ticket recorded was not that the delay was unmentioned, it was that every
* plausible reading of "API key not found" is wrong: create the key again (which
* costs another human approval and mints nothing), escalate, or report MCP key
* creation as broken. So the note names the code, the exact words the proxy
* uses, and what to do about them.
*
* WHY IT IS NOT IN THE SHARED HANDOFF STRING. mgmt_reveal_api_key renders the
* same endpoint surface for a key that already exists and is therefore already
* known to the proxy. Warning about a wait there would be false in the other
* direction.
*/
export const NEW_KEY_PROPAGATION_NOTE =
"\n\nIF THIS KEY WAS JUST MINTED, THE PROXY NEEDS A MOMENT. The control " +
"plane creates a key at once; the RPC proxy learns it afterwards, measured " +
"at roughly 60 to 90 seconds on 2026-08-07. Until then rpc.ankr.com answers " +
"HTTP 401 with `API key not found` (json-rpc code -32050) for this token. " +
"That is the key not being visible YET, not a failed creation: do not create " +
"it again, and do not report it as broken. This call is idempotent by slot, " +
"so a key that already existed is already known to the proxy and is callable " +
"now.";

/**
* Turn a key into something the caller can call, or say plainly why not.
*
Expand All @@ -146,9 +233,17 @@ const DATA_CALL_HANDOFF =
export async function describeEndpointToken({
key,
worker,
justCreated = false,
}: {
key: { jwt_data?: string; is_encrypted: boolean; config?: string };
worker?: WorkerClient;
/**
* SHARK-3619 — true only on the create path, where the key may be seconds old
* and the proxy may not know it yet. The caveat rides on the ONE branch that
* hands over a usable URL: the encrypted / no-material / exchange-failed
* branches promise no immediacy to correct.
*/
justCreated?: boolean;
}): Promise<{ ok: boolean; text: string }> {
if (key.is_encrypted) {
return {
Expand Down Expand Up @@ -181,6 +276,7 @@ export async function describeEndpointToken({
"on the chains the key is scoped to. The same value is what the " +
"allowlist, freeze and status tools take as `token`." +
DATA_CALL_HANDOFF +
(justCreated ? NEW_KEY_PROPAGATION_NOTE : "") +
enterpriseSurface(resolved),
};
} catch (e) {
Expand Down
Loading