Skip to content

fix(frontend): stop the MCP Usage tab calling an unknown owner "Personal" - #7480

Merged
iskhakov merged 8 commits into
mainfrom
task-envs/mmhb92
Aug 27, 2026
Merged

fix(frontend): stop the MCP Usage tab calling an unknown owner "Personal"#7480
iskhakov merged 8 commits into
mainfrom
task-envs/mmhb92

Conversation

@archestra-task-envs

@archestra-task-envs archestra-task-envs Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The bug

On an MCP server's Usage tab, the Owner column is inconsistent: some
personal agents are attributed to an email address, while others — including
agents belonging to the person looking at the page — just say "Personal". So
"Personal" cannot be read as "this one is yours", and two rows for the same
owner can disagree.

mcp-server-usage-tab.tsx rendered the cell like this:

const owner = agentOwnerLabel(agent); // agent.ownerEmail, for personal agents
...
{owner ? <span>{owner}</span> : <span>{scopeLabel(agent.scope)}</span>}

agentOwnerLabel returns ownerEmail for a personal agent and null
otherwise, so the fallback is doing two unrelated jobs at once:

  • for a team- or org-scoped agent it prints "Team" / "Organization",
    which is right — such an agent belongs to the team or the organization, so
    the scope is the owner;
  • for a personal agent with no email it prints "Personal", which answers a
    different question than the column asks. It is a visibility scope wearing the
    clothes of an owner, and it reads as "mine".

Why the email was missing

ownerEmail comes from a LEFT JOIN onto the agent's author
(AgentToolModel.getAssignedAgentDetailsForMcpServers and
AgentModel.getAutoModeAgentDetailsByOrganizations), and agents.author_id is
declared ON DELETE SET NULL.

The user row is genuinely deleted, and by an ordinary flow: removing someone
from an organization deletes their account outright once it was their last
membership —

// routes/auth.ts, POST /api/auth/organization/remove-member
const hasRemainingMemberships = await MemberModel.hasAnyMembership(userId);
if (!hasRemainingMemberships) {
  await UserModel.delete(userId);
}

So every agent a departing colleague authored kept its row and lost its author,
with no name or email left anywhere to recover. Those are exactly the rows that
came out labelled "Personal", sitting in the same table as rows that named a
real person.

The Usage tab was also the only surface that took this fallback. The two other
consumers of agentOwnerLabel (the registry card's "used by" tooltip and the
uninstall dialog) append the owner to an agent's name and render nothing when
there isn't one, which was already correct.

The fix

1. Carry the owner's id, not just their email

McpServerAgentUsageSchema gains ownerId (agents.author_id), selected in
both queries that build the shape. Deciding "is this row mine" by matching the
session's email against a display string is the wrong instrument, and that
string is null on precisely the rows that most need telling apart.

2. Say the author is gone, without storing anything

For a personal agent, author_id IS NULL already means "the author's account
was deleted": every create path stamps an author, and both routes that could
make an existing agent personal (PUT /api/agents/:id and the bulk visibility
route) refuse to do so without one. So there is no other way into that state,
and the cell can derive "Deleted user" from the shape of the data.

An earlier revision of this PR added an agents.deleted_author_email snapshot
column so the row could name which person. That was the wrong shape and it is
gone. Sixteen tables lose a user identity when an account is deleted —
audit_log.actor_id among them — so a column on agents fixes one instance and
invites the next; it also introduced a bug of its own, a server-owned field that
turned out to be writable through the public agent APIs.

Naming the person requires the identity to survive, which means not destroying
it: soft-deleting users instead of hard-deleting them, so all sixteen tables
keep their authorship and no table needs a column. That is a change to
UserModel.delete and to the auth read paths — account and session cascade
today, user.email is hard-unique, and better-auth owns every sign-in query —
so it deserves its own PR rather than riding along with a UI fix.

3. One explicit decision instead of a fallback

describeAgentOwner (lib/agent-owner-label.ts) replaces the ternary with a
discriminated result the cell renders branch by branch:

row Owner column
your own personal agent You (the email stays in the title)
someone else's personal agent their email
personal agent whose author's account was deleted Deleted user
team- / org-scoped agent Team / Organization

The personal scope label no longer appears in that column at all, so nothing in
it can be misread as an owner. "Deleted user" stays in front of the address
deliberately: a bare address invites the reader to go and ask a colleague who
has left. Unknown is now only reachable for agents orphaned before the snapshot
existed — a set that only shrinks.

agentOwnerLabel remains, documented as the short qualifier form, for the two
name-suffix surfaces above.

Notes

  • Pinned in mcp-server-usage-tab.test.tsx: the viewer's own agent renders
    "You" and never "Personal", and an authorless personal agent renders "Deleted
    user" — not "Personal", not "You". Those are the readings the bug conflated,
    and they are decided entirely by props, so an e2e would add fixtures without
    testing anything more.
  • Verified against the running dev stack: seeded a scene covering every branch,
    then removed two members through the real deletion path and watched their
    rows read "Deleted user". Checked at desktop and narrow widths, no new
    console errors.
  • Follow-up worth filing: users are hard-deleted, and sixteen tables carry
    a SET NULL FK to them, so every account removal silently erases authorship
    across the platform — including audit_log.actor_id, which loses its actor.
    Soft-deleting users fixes all of them at once and removes any need for
    per-table snapshots. It also carries a retention question of its own, since
    keeping the row keeps the email.
  • The retained identity is now available to any surface with the same problem —
    the Agents list's "Accessible to" column still renders - for a deleted
    author — but this PR changes only the column that was reported.
  • I could not read the originating discussion thread from this environment (the
    MCP gateway rejected this session's token), so this works from the
    second-hand summary of it.

Archestra Contributor

…n MCP usage

`McpServerAgentUsage` — the shape behind the registry card's "used by"
tooltip and the server's Usage tab — carried `scope` and `ownerEmail`
only. Both queries that build it (`AgentToolModel
.getAssignedAgentDetailsForMcpServers`, `AgentModel
.getAutoModeAgentDetailsByOrganizations`) LEFT JOIN the author, so
`ownerEmail` is null for an agent whose author no longer exists —
`agents.author_id` is ON DELETE SET NULL.

That leaves a surface with no way to say "this one is yours" on exactly
the rows where it matters most. Select `agents.author_id` as `ownerId`
in both queries so identity, not a display string, answers it.
…nal"

The Owner column printed the agent's owner email, and fell back to
`scopeLabel(agent.scope)` when there wasn't one. For a personal agent
that fallback is the word "Personal" — a statement about visibility
dressed up as an answer to "whose is this". Two rows for the same person
could therefore disagree: one spelled out an email, the next said
"Personal", and neither told the viewer which agents were their own.

Replace the fallback with an explicit decision, `describeAgentOwner`:
the viewer's own personal agents read "You" (email kept in the title),
someone else's read their email, a personal agent with no author on
record reads "Unknown", and team/org agents keep their scope — there the
scope really is the owner. The personal scope label no longer appears in
that column at all.

The unknown case is a real and permanent state, not a loading gap:
`agents.author_id` is ON DELETE SET NULL, so a deleted account leaves
its personal agents authorless for good.

`agentOwnerLabel` stays for the two surfaces that append the owner to an
agent's name (registry card tooltip, uninstall dialog), where rendering
nothing was already the right answer for an unattributable agent.
…eleted

Removing someone's last organization membership hard-deletes their `user`
row (`routes/auth.ts` -> `UserModel.delete`), and `agents.author_id` is
`ON DELETE SET NULL`. So an agent that outlives its author keeps the row
and loses every trace of who wrote it — no id, no name, no email. Any
surface that names an owner can then do no better than shrug at a
perfectly ordinary departed colleague's agent.

Add `agents.deleted_author_name` / `deleted_author_email`, written by
`AgentModel.snapshotAuthorIdentityForDeletion` immediately before the
user row goes, from both deletion paths: `UserModel.delete` for the
app-driven removals and better-auth's `user.delete.before` hook for the
self-service endpoint. It is idempotent and returns early once the user
is gone, so the paths overlapping is harmless and a re-run cannot
overwrite a good snapshot with nulls.

Written only at deletion time, never on create: while the author exists
`author_id` is the single source of truth and a denormalised copy would
drift the moment they changed their email. The pair `author_id IS NULL
AND deleted_author_email IS NOT NULL` therefore means exactly "the
author's account was deleted, and this is who they were".

`McpServerAgentUsage` carries the retained identity as `formerOwnerName`
/ `formerOwnerEmail`, kept separate from the live `ownerId`/`ownerEmail`
rather than coalesced into them, so a consumer cannot mistake a dead
address for a current one.

Agents orphaned before this existed have nothing to recover and stay
unattributed; the set only shrinks from here.
Users are hard-deleted (no soft delete on `user`), so an agent that
outlives its author needs something retained to name them at all — but
one column is enough. The Owner column speaks emails on every other row,
and an email is the identifier that does not collide, so carrying the
display name as well bought a second column, a preferred/fallback rule
and a title attribute for no added clarity.

Drops `agents.deleted_author_name`; `deleted_author_email` alone backs
`formerOwnerEmail`, and the cell reads "Deleted user (kim@example.com)".
…hemas

`InsertAgentSchemaBase` / `UpdateAgentSchemaBase` are derived from the
agents table and omit the server-owned columns one by one, so the new
`deleted_author_email` was accepted from a request body and written
straight through by `AgentModel.create`/`update`.

That is a forgeable attribution, not just a stray field: the MCP Usage
tab renders the column as "Deleted user (<address>)", so any caller who
could create an agent could make it claim it once belonged to whoever
they named. Omit it in both schemas, alongside `author_id`, whose
absence it stands in for.

The route test asserts the supplied value does not reach the row — it
fails on the previous revision with the forged address persisted.

Also moves the `deletePersonalMcpGatewaysForUser` docblock back above
its own method; inserting the snapshot helper ahead of it had left the
comment describing the wrong function.
Omitting `deletedAuthorEmail` from the agent create/update schemas
narrows both request bodies, and the generated artifacts still
advertised the field. Regenerated: `CreateAgentData` and
`UpdateAgentData` no longer accept it.
… owner

`types/mcp-server.ts` now holds two different "owner"s: the agent's
author on `McpServerAgentUsageSchema`, and the person who installed the
server on `SelectMcpServerSchema`. Say so at the point of confusion.

The field is named for the pre-existing `ownerEmail` it sits beside
rather than for its column, so the pair stays consistent. Renaming both
to `author*` would match `SelectAgentSchema` and read better, but it
reaches `ToolDelegationTarget` and around twenty consumers of a
pre-existing field, so it belongs in its own change rather than riding
along with a bug fix.
The snapshot column treated a symptom. Sixteen tables lose a user
identity when an account goes — `audit_log.actor_id` among them — so a
column on `agents` fixes one instance and invites the next one. It also
introduced its own bug: a writable server-owned field, forgeable through
the public agent APIs.

Nothing needs storing to say the author is gone. For a personal agent,
`author_id IS NULL` already means exactly that: every create path stamps
an author, and both routes that could make an existing agent personal
refuse to do so without one, so there is no other way into that state.
The cell now derives "Deleted user" from the shape of the data.

Removed with it: the migration, `deleted_author_name`/`_email`, the
snapshot helper and its two deletion-path hooks, the `.omit()` guard that
existed only to protect the column, and the tests for all of it. What
remains is `ownerId` on the usage payload and the decision that reads it.

Naming WHICH deleted user needs the identity to survive, which means not
destroying it — soft-deleting users, so all sixteen tables keep their
authorship. That is a change to user deletion and the auth read paths,
not to this table, and it belongs in its own reviewed PR.
@iskhakov
iskhakov added this pull request to the merge queue Aug 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 26, 2026
@iskhakov
iskhakov added this pull request to the merge queue Aug 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 26, 2026
@iskhakov
iskhakov added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit a2bd3ef Aug 27, 2026
47 of 48 checks passed
@iskhakov
iskhakov deleted the task-envs/mmhb92 branch August 27, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant