Skip to content

Commit d68b8a7

Browse files
Ripwordsclaude
andcommitted
feat(mcp): add /api/me/mcp-connections list + revoke endpoints
GET returns all OAuth consents for the current user with client name, scopes, connectedAt, and last-used timestamp. DELETE transactionally revokes consent, access tokens, and refresh tokens for a given client; idempotent (returns revoked: false if no consent existed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 89dda29 commit d68b8a7

2 files changed

Lines changed: 92 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { defineEventHandler } from "h3"
2+
import { eq, desc } from "drizzle-orm"
3+
import { db } from "../../db"
4+
import { oauthConsent, oauthClient, oauthAccessToken } from "../../db/schema/auth-schema"
5+
import { requireSession } from "../../lib/permissions"
6+
7+
/**
8+
* Returns the OAuth consents (= connected MCP apps) for the current user.
9+
* Each entry includes the client name (from RFC 7591 registration), connected
10+
* date, last-used timestamp, and the scopes granted.
11+
*/
12+
export default defineEventHandler(async (event) => {
13+
const session = await requireSession(event)
14+
15+
const consents = await db
16+
.select({
17+
clientId: oauthConsent.clientId,
18+
scopes: oauthConsent.scopes,
19+
createdAt: oauthConsent.createdAt,
20+
clientName: oauthClient.name,
21+
})
22+
.from(oauthConsent)
23+
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthConsent.clientId))
24+
.where(eq(oauthConsent.userId, session.userId))
25+
.orderBy(desc(oauthConsent.createdAt))
26+
27+
// Last-used per client = latest access token's createdAt for that (user, client) pair.
28+
const lastUsedByClient = new Map<string, Date>()
29+
const lastUsedResults = await Promise.all(
30+
consents.map((c) =>
31+
db
32+
.select({ createdAt: oauthAccessToken.createdAt })
33+
.from(oauthAccessToken)
34+
.where(eq(oauthAccessToken.clientId, c.clientId))
35+
.orderBy(desc(oauthAccessToken.createdAt))
36+
.limit(1)
37+
.then((rows) => ({ clientId: c.clientId, createdAt: rows[0]?.createdAt ?? null })),
38+
),
39+
)
40+
for (const r of lastUsedResults) {
41+
if (r.createdAt) lastUsedByClient.set(r.clientId, r.createdAt)
42+
}
43+
44+
return {
45+
connections: consents.map((c) => ({
46+
clientId: c.clientId,
47+
clientName: c.clientName ?? "Unknown",
48+
scopes: c.scopes ?? [],
49+
connectedAt: c.createdAt,
50+
lastUsedAt: lastUsedByClient.get(c.clientId) ?? null,
51+
})),
52+
}
53+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createError, defineEventHandler, getRouterParam } from "h3"
2+
import { and, eq } from "drizzle-orm"
3+
import { db } from "../../../db"
4+
import { oauthConsent, oauthAccessToken, oauthRefreshToken } from "../../../db/schema/auth-schema"
5+
import { requireSession } from "../../../lib/permissions"
6+
7+
/**
8+
* Revoke an MCP client's access for the current user. Deletes:
9+
* 1. The consent grant (so future authorize attempts will re-prompt)
10+
* 2. All access tokens for this (user, client) pair
11+
* 3. All refresh tokens for this (user, client) pair
12+
*
13+
* Idempotent — returns { revoked: false } if no consent existed.
14+
*/
15+
export default defineEventHandler(async (event) => {
16+
const session = await requireSession(event)
17+
const clientId = getRouterParam(event, "clientId")
18+
if (!clientId) throw createError({ statusCode: 400, statusMessage: "missing clientId" })
19+
20+
return await db.transaction(async (tx) => {
21+
const deleted = await tx
22+
.delete(oauthConsent)
23+
.where(and(eq(oauthConsent.userId, session.userId), eq(oauthConsent.clientId, clientId)))
24+
.returning({ clientId: oauthConsent.clientId })
25+
if (deleted.length === 0) return { revoked: false }
26+
27+
await tx
28+
.delete(oauthAccessToken)
29+
.where(
30+
and(eq(oauthAccessToken.userId, session.userId), eq(oauthAccessToken.clientId, clientId)),
31+
)
32+
await tx
33+
.delete(oauthRefreshToken)
34+
.where(
35+
and(eq(oauthRefreshToken.userId, session.userId), eq(oauthRefreshToken.clientId, clientId)),
36+
)
37+
return { revoked: true }
38+
})
39+
})

0 commit comments

Comments
 (0)