@12-apps/mcp 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ADOPTING.md CHANGED
@@ -109,11 +109,14 @@ library updates, every host updates with **no app changes**. Same contract
109
109
  FENCED: a failing directory can never turn a valid grant into a 500, and nothing
110
110
  about the attempt is logged, because the only values in hand are an email and a
111
111
  client id.
112
- 11. **Disconnecting means BOTH halves.** `connections.revokeByHost(...)` returns the
113
- OAuth client ids it revoked, and the caller must then
114
- `refreshTokens.revokeLiveForClient(email, clientId)` for each a host holding a
115
- live refresh token simply rotates its way back in and the card lights green on
116
- the next grant. Neither half invalidates an outstanding ACCESS token: those are
112
+ 11. **Disconnecting means BOTH halves — call `disconnectAiHost`, not the stores.**
113
+ `connections.revokeByHost(...)` ends the rows and returns the OAuth client ids
114
+ behind them, and every live refresh token of those clients must be ended in the
115
+ same act — a host holding a live refresh token simply rotates its way back in
116
+ and the card lights green on the next grant. Since 12-48 the rule IS a
117
+ function: `disconnectAiHost(stores, { userId, email }, host)` does both and
118
+ reports what it ended, so a host cannot import one half without the other.
119
+ Neither half invalidates an outstanding ACCESS token: those are
117
120
  self-contained JWTs, so a disconnected host keeps working for at most their
118
121
  15-minute TTL and can then obtain nothing further.
119
122
  12. **These bodies are NOT the `{ data }` envelope.** A 302 with a `Location`, RFC
@@ -234,10 +237,18 @@ Deliberate deltas to reconcile:
234
237
 
235
238
  ## What deliberately did NOT move into the package
236
239
 
237
- - **The account/connection SCREENS' endpoints** (`GET/DELETE
238
- /api/account/mcp-connections`) — they mix session resolution, published plugin
239
- URLs and a logger. The stores they need (`listActive`, `revokeByHost`,
240
- `revokeLiveForClient`) are all here; the route is a follow-up.
240
+ - **The account/connection SCREENS' route files** (`GET/DELETE
241
+ /api/account/mcp-connections`) — they answer in the HOST's app-wide response
242
+ envelope and mix its session resolution, published plugin URLs and logger, so
243
+ the handler stays host code (unlike the OAuth endpoints, whose shapes are
244
+ fixed by RFC — rule 12 — these are ordinary host API routes). What DID move
245
+ (12-48) is the operations under them: `listAiConnections` (the stored open
246
+ `host` string narrowed to the package's own `AiProvider` union) and
247
+ `disconnectAiHost`, which owns the disconnect's both-halves rule — revoke the
248
+ connection rows AND end every live refresh token of each returned client id in
249
+ one call. A host that imports the disconnect cannot get only half of it; half
250
+ is the failure mode where the assistant rotates its live token and the card
251
+ the user just disconnected lights green again on the next grant (rule 11).
241
252
  - **The MCP registry itself** — which endpoints become tools, their annotations
242
253
  and redactions, is the host's catalogue. The package generates, dispatches and
243
254
  gates it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/mcp",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call carrying the caller's bearer token (permission passthrough). Also ships the OAuth 2.1 authorization server (./oauth, ./hono: register/authorize/token, JWKS and both .well-known documents), the package-owned Prisma partial + migration for its three tables, the mcp:generate/mcp:check (./generate) and mcp:coverage (./coverage) gates, and the reusable AI-connect onboarding UI (./react).",
6
6
  "exports": {
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@12-apps/onboarding": "^2.0.0",
27
- "@12-apps/rbac": "^4.0.0",
27
+ "@12-apps/rbac": "^4.0.1",
28
28
  "@12-apps/ui": "^5.0.0",
29
29
  "@mui/icons-material": "^6.5.0",
30
30
  "jose": "^6.1.3",
@@ -0,0 +1,104 @@
1
+ import { providerForHostId, type AiProvider } from "../guide";
2
+ import type {
3
+ McpConnectionStore,
4
+ RefreshTokenStore,
5
+ StoredMcpConnection,
6
+ } from "./stores";
7
+
8
+ /**
9
+ * The account surface's connection OPERATIONS (12-48) — the half of the
10
+ * `GET/DELETE /api/account/mcp-connections` endpoints that is contract rather
11
+ * than host vocabulary.
12
+ *
13
+ * The ROUTE stays in the host on purpose: it mixes the host's session
14
+ * resolution, its response envelope, its published plugin URLs and its logger,
15
+ * and injecting all four here would make the config surface bigger than the
16
+ * handler it replaces. What must NOT stay in each host is the disconnect's
17
+ * both-halves rule, because getting it half right LOOKS right:
18
+ *
19
+ * `connections.revokeByHost` ends the connection rows and returns the OAuth
20
+ * client ids behind them — and a host that stops there has revoked nothing that
21
+ * matters. The assistant still holds a live refresh token for each of those
22
+ * clients, rotates it on schedule, and the very next grant records fresh
23
+ * activity: the card the user just disconnected lights green again on its own.
24
+ * So the rule is one function: revoke the rows AND end every live refresh token
25
+ * of each returned client, in the same call, with no way to import one half
26
+ * without the other.
27
+ *
28
+ * Deliberately NOT invalidated here: the assistant's current ACCESS token.
29
+ * Those are self-contained JWTs the server does not track; a just-disconnected
30
+ * host keeps working for at most their TTL (15 minutes by default) and can then
31
+ * obtain nothing further.
32
+ */
33
+
34
+ /** An active AI connection, narrowed for display. */
35
+ export interface AiConnectionSnapshot {
36
+ oauthClientId: string;
37
+ clientName: string | null;
38
+ /** The provider this connection is attributed to (`null` = pre-attribution). */
39
+ host: AiProvider | null;
40
+ connectedAt: Date;
41
+ lastActiveAt: Date;
42
+ }
43
+
44
+ /** The caller the operations act for — always the session's own user. */
45
+ export interface AiConnectionCaller {
46
+ /** The host's user id — what `mcp_connections` rows are keyed by. */
47
+ userId: string;
48
+ /** The identity refresh tokens are bound to (the AS binds by email). */
49
+ email: string;
50
+ }
51
+
52
+ /** What one disconnect actually ended, for the host's log and response. */
53
+ export interface AiDisconnectResult {
54
+ /** OAuth client ids whose connection rows were revoked. */
55
+ disconnectedClientIds: string[];
56
+ /** Live refresh tokens ended across those clients — the half that cuts access. */
57
+ revokedRefreshTokens: number;
58
+ }
59
+
60
+ /** Narrow a stored `host` string to a known provider, or `null`. */
61
+ function asProvider(host: string | null): AiProvider | null {
62
+ return host === null ? null : providerForHostId(host);
63
+ }
64
+
65
+ /**
66
+ * A user's active connections, most-recently-active first, with the stored open
67
+ * `host` string narrowed to the package's closed {@link AiProvider} union — the
68
+ * store cannot know which assistants have screens, but the union is this
69
+ * package's own vocabulary (`guide.ts`), so the narrowing lives beside it
70
+ * rather than being re-derived in every host.
71
+ */
72
+ export async function listAiConnections(
73
+ connections: McpConnectionStore,
74
+ userId: string,
75
+ ): Promise<AiConnectionSnapshot[]> {
76
+ const rows: StoredMcpConnection[] = await connections.listActive(userId);
77
+ return rows.map((row) => ({ ...row, host: asProvider(row.host) }));
78
+ }
79
+
80
+ /**
81
+ * Disconnect one provider for this user — BOTH halves, atomically from the
82
+ * caller's point of view (see the module doc for why one half alone is a
83
+ * disconnect that undoes itself).
84
+ *
85
+ * Idempotent: disconnecting a provider that was never connected returns zero
86
+ * counts rather than failing, so a double-click is harmless. Repeat calls also
87
+ * report zero — `revokeLiveForClient` skips already-revoked tokens by contract.
88
+ */
89
+ export async function disconnectAiHost(
90
+ stores: { connections: McpConnectionStore; refreshTokens: RefreshTokenStore },
91
+ caller: AiConnectionCaller,
92
+ host: AiProvider,
93
+ ): Promise<AiDisconnectResult> {
94
+ const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);
95
+ const revoked = await Promise.all(
96
+ disconnectedClientIds.map((clientId) =>
97
+ stores.refreshTokens.revokeLiveForClient(caller.email, clientId),
98
+ ),
99
+ );
100
+ return {
101
+ disconnectedClientIds,
102
+ revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0),
103
+ };
104
+ }
@@ -115,3 +115,10 @@ export type {
115
115
  StoredRefreshToken,
116
116
  TokenEndpointAuthMethod,
117
117
  } from "./stores";
118
+ export {
119
+ disconnectAiHost,
120
+ listAiConnections,
121
+ type AiConnectionCaller,
122
+ type AiConnectionSnapshot,
123
+ type AiDisconnectResult,
124
+ } from "./connections";