@12-apps/mcp 3.11.0 → 3.13.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/dist/chunk-VDD4YRNP.js +197 -0
- package/dist/chunk-VDD4YRNP.js.map +1 -0
- package/dist/{create-api-mcp-oauth-CwVXKK-A.d.ts → create-api-mcp-oauth-CsC0jlH7.d.ts} +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/{generate-Dx3cK8th.d.ts → generate-BCDqBUjZ.d.ts} +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/index.d.ts +95 -3
- package/dist/index.js +41 -8
- package/dist/index.js.map +1 -1
- package/dist/manifest/index.d.ts +93 -0
- package/dist/manifest/index.js +22 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/manifest/server.d.ts +74 -0
- package/dist/manifest/server.js +41 -0
- package/dist/manifest/server.js.map +1 -0
- package/dist/oauth/index.d.ts +2 -2
- package/dist/oauth/index.js +6 -189
- package/dist/oauth/index.js.map +1 -1
- package/package.json +33 -14
- package/src/index.ts +10 -1
- package/src/manifest/index.ts +89 -0
- package/src/manifest/server.ts +94 -0
- package/src/openapi/annotations.ts +106 -0
- package/src/openapi/endpoint.ts +36 -0
package/dist/oauth/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
createPrismaMcpStores,
|
|
3
|
+
disconnectAiHost,
|
|
4
|
+
listAiConnections
|
|
5
|
+
} from "../chunk-VDD4YRNP.js";
|
|
6
|
+
import "../chunk-FBUSSQSK.js";
|
|
4
7
|
import {
|
|
5
8
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
6
9
|
AUTHORIZATION_CODE_AUDIENCE,
|
|
@@ -44,193 +47,7 @@ import {
|
|
|
44
47
|
verifyCode
|
|
45
48
|
} from "../chunk-UIILEGAC.js";
|
|
46
49
|
import "../chunk-WJJNKKNS.js";
|
|
47
|
-
import
|
|
48
|
-
__name
|
|
49
|
-
} from "../chunk-7QVYU63E.js";
|
|
50
|
-
|
|
51
|
-
// src/oauth/prisma-stores.ts
|
|
52
|
-
function clientStore(getPrisma) {
|
|
53
|
-
return {
|
|
54
|
-
async create(client) {
|
|
55
|
-
const prisma = await getPrisma();
|
|
56
|
-
return prisma.oAuthClient.create({ data: client });
|
|
57
|
-
},
|
|
58
|
-
async findByClientId(clientId) {
|
|
59
|
-
const prisma = await getPrisma();
|
|
60
|
-
return prisma.oAuthClient.findUnique({ where: { clientId } });
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
__name(clientStore, "clientStore");
|
|
65
|
-
function refreshTokenStore(getPrisma) {
|
|
66
|
-
return {
|
|
67
|
-
async create(token) {
|
|
68
|
-
const prisma = await getPrisma();
|
|
69
|
-
await prisma.oAuthRefreshToken.create({ data: token });
|
|
70
|
-
},
|
|
71
|
-
async findByHash(tokenHash) {
|
|
72
|
-
const prisma = await getPrisma();
|
|
73
|
-
return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });
|
|
74
|
-
},
|
|
75
|
-
async hasSuccessor(tokenHash) {
|
|
76
|
-
const prisma = await getPrisma();
|
|
77
|
-
const successor = await prisma.oAuthRefreshToken.findFirst({
|
|
78
|
-
where: { rotatedFrom: tokenHash }
|
|
79
|
-
});
|
|
80
|
-
return successor !== null;
|
|
81
|
-
},
|
|
82
|
-
async listFamily(userEmail, clientId) {
|
|
83
|
-
const prisma = await getPrisma();
|
|
84
|
-
return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });
|
|
85
|
-
},
|
|
86
|
-
async revokeHashes(tokenHashes, at) {
|
|
87
|
-
if (tokenHashes.length === 0) return;
|
|
88
|
-
const prisma = await getPrisma();
|
|
89
|
-
await prisma.oAuthRefreshToken.updateMany({
|
|
90
|
-
where: { tokenHash: { in: [...tokenHashes] } },
|
|
91
|
-
data: { revokedAt: at }
|
|
92
|
-
});
|
|
93
|
-
},
|
|
94
|
-
async rotate(successor, parentHash, at) {
|
|
95
|
-
const prisma = await getPrisma();
|
|
96
|
-
return prisma.$transaction(async (tx) => {
|
|
97
|
-
const { count } = await tx.oAuthRefreshToken.updateMany({
|
|
98
|
-
where: { tokenHash: parentHash, revokedAt: null },
|
|
99
|
-
data: { revokedAt: at }
|
|
100
|
-
});
|
|
101
|
-
if (count !== 1) return false;
|
|
102
|
-
await tx.oAuthRefreshToken.create({ data: successor });
|
|
103
|
-
return true;
|
|
104
|
-
});
|
|
105
|
-
},
|
|
106
|
-
async revokeLiveForClient(userEmail, clientId) {
|
|
107
|
-
const prisma = await getPrisma();
|
|
108
|
-
const { count } = await prisma.oAuthRefreshToken.updateMany({
|
|
109
|
-
where: { userEmail, clientId, revokedAt: null },
|
|
110
|
-
data: { revokedAt: /* @__PURE__ */ new Date() }
|
|
111
|
-
});
|
|
112
|
-
return count;
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
__name(refreshTokenStore, "refreshTokenStore");
|
|
117
|
-
var CONNECTION_SELECT = {
|
|
118
|
-
oauthClientId: true,
|
|
119
|
-
clientName: true,
|
|
120
|
-
host: true,
|
|
121
|
-
connectedAt: true,
|
|
122
|
-
lastActiveAt: true
|
|
123
|
-
};
|
|
124
|
-
function connectionStore(getPrisma) {
|
|
125
|
-
return {
|
|
126
|
-
async lastActiveAt(userId, oauthClientId) {
|
|
127
|
-
const prisma = await getPrisma();
|
|
128
|
-
const row = await prisma.mcpConnection.findUnique({
|
|
129
|
-
where: { userId_oauthClientId: { userId, oauthClientId } },
|
|
130
|
-
select: { lastActiveAt: true }
|
|
131
|
-
});
|
|
132
|
-
return row?.lastActiveAt ?? null;
|
|
133
|
-
},
|
|
134
|
-
async recordActivity({ userId, oauthClientId, clientName, host, at }) {
|
|
135
|
-
const prisma = await getPrisma();
|
|
136
|
-
await prisma.mcpConnection.upsert({
|
|
137
|
-
where: { userId_oauthClientId: { userId, oauthClientId } },
|
|
138
|
-
create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },
|
|
139
|
-
// Never blank a known host on refresh — keep the existing attribution
|
|
140
|
-
// when this grant cannot derive one.
|
|
141
|
-
update: {
|
|
142
|
-
clientName,
|
|
143
|
-
lastActiveAt: at,
|
|
144
|
-
revokedAt: null,
|
|
145
|
-
...host ? { host } : {}
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
},
|
|
149
|
-
async listActive(userId) {
|
|
150
|
-
const prisma = await getPrisma();
|
|
151
|
-
const rows = await prisma.mcpConnection.findMany({
|
|
152
|
-
where: { userId, revokedAt: null },
|
|
153
|
-
orderBy: { lastActiveAt: "desc" },
|
|
154
|
-
select: { ...CONNECTION_SELECT }
|
|
155
|
-
});
|
|
156
|
-
return rows;
|
|
157
|
-
},
|
|
158
|
-
revokeByHost: /* @__PURE__ */ __name((userId, host) => revokeByHost(getPrisma, userId, host), "revokeByHost"),
|
|
159
|
-
announce: /* @__PURE__ */ __name((userId, host) => announce(getPrisma, userId, host), "announce")
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
__name(connectionStore, "connectionStore");
|
|
163
|
-
async function revokeByHost(getPrisma, userId, host) {
|
|
164
|
-
const prisma = await getPrisma();
|
|
165
|
-
const attributed = await prisma.mcpConnection.findMany({
|
|
166
|
-
where: { userId, revokedAt: null, host },
|
|
167
|
-
select: { id: true, oauthClientId: true }
|
|
168
|
-
});
|
|
169
|
-
const targets = attributed.length > 0 ? attributed : await prisma.mcpConnection.findMany({
|
|
170
|
-
where: { userId, revokedAt: null, host: null },
|
|
171
|
-
select: { id: true, oauthClientId: true }
|
|
172
|
-
});
|
|
173
|
-
if (targets.length === 0) return [];
|
|
174
|
-
await prisma.mcpConnection.updateMany({
|
|
175
|
-
where: { id: { in: targets.map((row) => String(row.id)) } },
|
|
176
|
-
data: { revokedAt: /* @__PURE__ */ new Date() }
|
|
177
|
-
});
|
|
178
|
-
return targets.map((row) => String(row.oauthClientId));
|
|
179
|
-
}
|
|
180
|
-
__name(revokeByHost, "revokeByHost");
|
|
181
|
-
async function announce(getPrisma, userId, host) {
|
|
182
|
-
const prisma = await getPrisma();
|
|
183
|
-
const now = /* @__PURE__ */ new Date();
|
|
184
|
-
const refreshed = await prisma.mcpConnection.updateMany({
|
|
185
|
-
where: { userId, revokedAt: null, host },
|
|
186
|
-
data: { lastActiveAt: now, revokedAt: null }
|
|
187
|
-
});
|
|
188
|
-
if (refreshed.count > 0) return refreshed.count;
|
|
189
|
-
const candidate = await prisma.mcpConnection.findFirst({
|
|
190
|
-
where: { userId, revokedAt: null, host: null },
|
|
191
|
-
orderBy: { lastActiveAt: "desc" },
|
|
192
|
-
select: { id: true }
|
|
193
|
-
});
|
|
194
|
-
if (!candidate) return 0;
|
|
195
|
-
await prisma.mcpConnection.update({
|
|
196
|
-
where: { id: candidate.id },
|
|
197
|
-
data: { host, lastActiveAt: now, revokedAt: null }
|
|
198
|
-
});
|
|
199
|
-
return 1;
|
|
200
|
-
}
|
|
201
|
-
__name(announce, "announce");
|
|
202
|
-
function createPrismaMcpStores(getPrisma) {
|
|
203
|
-
return {
|
|
204
|
-
clients: clientStore(getPrisma),
|
|
205
|
-
refreshTokens: refreshTokenStore(getPrisma),
|
|
206
|
-
connections: connectionStore(getPrisma)
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
__name(createPrismaMcpStores, "createPrismaMcpStores");
|
|
210
|
-
|
|
211
|
-
// src/oauth/connections.ts
|
|
212
|
-
function asProvider(host) {
|
|
213
|
-
return host === null ? null : providerForHostId(host);
|
|
214
|
-
}
|
|
215
|
-
__name(asProvider, "asProvider");
|
|
216
|
-
async function listAiConnections(connections, userId) {
|
|
217
|
-
const rows = await connections.listActive(userId);
|
|
218
|
-
return rows.map((row) => ({ ...row, host: asProvider(row.host) }));
|
|
219
|
-
}
|
|
220
|
-
__name(listAiConnections, "listAiConnections");
|
|
221
|
-
async function disconnectAiHost(stores, caller, host) {
|
|
222
|
-
const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);
|
|
223
|
-
const revoked = await Promise.all(
|
|
224
|
-
disconnectedClientIds.map(
|
|
225
|
-
(clientId) => stores.refreshTokens.revokeLiveForClient(caller.email, clientId)
|
|
226
|
-
)
|
|
227
|
-
);
|
|
228
|
-
return {
|
|
229
|
-
disconnectedClientIds,
|
|
230
|
-
revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0)
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
__name(disconnectAiHost, "disconnectAiHost");
|
|
50
|
+
import "../chunk-7QVYU63E.js";
|
|
234
51
|
export {
|
|
235
52
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
236
53
|
AUTHORIZATION_CODE_AUDIENCE,
|
package/dist/oauth/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/oauth/prisma-stores.ts","../../src/oauth/connections.ts"],"sourcesContent":["import type {\n McpConnectionStore,\n McpOauthStores,\n NewOAuthClient,\n NewRefreshToken,\n OAuthClientStore,\n RefreshTokenStore,\n StoredMcpConnection,\n StoredOAuthClient,\n StoredRefreshToken,\n} from \"./stores\";\n\n/**\n * The ports of `./stores.ts`, filled by Prisma (12-23).\n *\n * The package owns the three models (`prisma/mcp.prisma`), so their delegate\n * shapes are known and this adapter can be exact. A host with Prisma therefore\n * writes ONE line —\n *\n * stores: createPrismaMcpStores(async () => getPrismaClient() as unknown as McpOauthPrisma)\n *\n * — and no host code at all beyond it. The client is duck-typed (only the\n * delegates used, only the arguments used) so this file never imports a project's\n * generated client, and a non-Prisma host fills the ports directly instead.\n */\n\n/** A `where` on the composite unique of `mcp_connections`. */\ninterface ConnectionKey {\n userId_oauthClientId: { userId: string; oauthClientId: string };\n}\n\n/** The minimal Prisma surface the AS needs. Every field is one the surface writes. */\nexport interface McpOauthPrisma {\n oAuthClient: {\n create(args: { data: NewOAuthClient }): Promise<StoredOAuthClient>;\n findUnique(args: { where: { clientId: string } }): Promise<StoredOAuthClient | null>;\n };\n oAuthRefreshToken: {\n create(args: { data: NewRefreshToken }): Promise<unknown>;\n findUnique(args: { where: { tokenHash: string } }): Promise<StoredRefreshToken | null>;\n findFirst(args: { where: { rotatedFrom: string } }): Promise<{ tokenHash: string } | null>;\n findMany(args: {\n where: { userEmail: string; clientId: string };\n }): Promise<StoredRefreshToken[]>;\n // No single-row `update`: the rotation used to revoke its parent with one and\n // that was the bug (unconditional, so two concurrent rotations both won). Every\n // revoke here is now an `updateMany` with a predicate that says WHICH rows may\n // move, which is also why this delegate list stays honest about what is written.\n updateMany(args: {\n where:\n | { tokenHash: { in: string[] } }\n | { userEmail: string; clientId: string; revokedAt: null };\n data: { revokedAt: Date };\n }): Promise<{ count: number }>;\n };\n mcpConnection: {\n findUnique(args: {\n where: ConnectionKey;\n select: { lastActiveAt: true };\n }): Promise<{ lastActiveAt: Date } | null>;\n findFirst(args: {\n where: { userId: string; revokedAt: null; host: null };\n orderBy: { lastActiveAt: \"desc\" };\n select: { id: true };\n }): Promise<{ id: string } | null>;\n findMany(args: {\n where: { userId: string; revokedAt: null; host?: string | null };\n orderBy?: { lastActiveAt: \"desc\" };\n select: Record<string, true>;\n }): Promise<Record<string, unknown>[]>;\n upsert(args: {\n where: ConnectionKey;\n create: Record<string, unknown>;\n update: Record<string, unknown>;\n }): Promise<unknown>;\n update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<unknown>;\n updateMany(args: {\n where: { id: { in: string[] } } | { userId: string; revokedAt: null; host: string };\n data: Record<string, unknown>;\n }): Promise<{ count: number }>;\n };\n /**\n * Prisma's INTERACTIVE transaction, used for the rotation's claim + write. The\n * callback form (not the array form) is required: the successor may only be\n * created once the conditional revoke has reported that it, and not a concurrent\n * sibling, claimed the parent — see `RefreshTokenStore.rotate`.\n */\n $transaction<T>(fn: (tx: McpOauthTx) => Promise<T>): Promise<T>;\n}\n\n/**\n * The delegate subset used INSIDE the rotation transaction. Not exported: it is\n * reachable structurally through `McpOauthPrisma.$transaction`, so no host ever\n * needs to name it, and exporting a type nobody imports is what knip flags.\n */\ninterface McpOauthTx {\n oAuthRefreshToken: {\n create(args: { data: NewRefreshToken }): Promise<unknown>;\n updateMany(args: {\n where: { tokenHash: string; revokedAt: null };\n data: { revokedAt: Date };\n }): Promise<{ count: number }>;\n };\n}\n\n/** A lazily-resolved client, so a host's singleton is awaited per call. */\nexport type McpOauthPrismaProvider = () => Promise<McpOauthPrisma>;\n\nfunction clientStore(getPrisma: McpOauthPrismaProvider): OAuthClientStore {\n return {\n async create(client: NewOAuthClient) {\n const prisma = await getPrisma();\n return prisma.oAuthClient.create({ data: client });\n },\n async findByClientId(clientId: string) {\n const prisma = await getPrisma();\n return prisma.oAuthClient.findUnique({ where: { clientId } });\n },\n };\n}\n\nfunction refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore {\n return {\n async create(token) {\n const prisma = await getPrisma();\n await prisma.oAuthRefreshToken.create({ data: token });\n },\n async findByHash(tokenHash) {\n const prisma = await getPrisma();\n return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });\n },\n async hasSuccessor(tokenHash) {\n const prisma = await getPrisma();\n const successor = await prisma.oAuthRefreshToken.findFirst({\n where: { rotatedFrom: tokenHash },\n });\n return successor !== null;\n },\n async listFamily(userEmail, clientId) {\n const prisma = await getPrisma();\n return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });\n },\n async revokeHashes(tokenHashes, at) {\n if (tokenHashes.length === 0) return;\n const prisma = await getPrisma();\n await prisma.oAuthRefreshToken.updateMany({\n where: { tokenHash: { in: [...tokenHashes] } },\n data: { revokedAt: at },\n });\n },\n async rotate(successor, parentHash, at) {\n const prisma = await getPrisma();\n return prisma.$transaction(async (tx) => {\n // CLAIM-ONCE. The `revokedAt: null` predicate is what makes this safe under\n // concurrency, and it is load-bearing rather than defensive: on Postgres's\n // default READ COMMITTED, a second transaction's `updateMany` blocks on the\n // row lock, then re-evaluates this WHERE against the COMMITTED row — which\n // now has a `revokedAt` — and reports 0 rows. So exactly one caller can ever\n // see count 1, and it is the only one that goes on to create a successor.\n // An unconditional `update` would let both through: two live successors of\n // one parent, and replay detection silently defeated (it waits for a third\n // use of the parent that now never comes).\n const { count } = await tx.oAuthRefreshToken.updateMany({\n where: { tokenHash: parentHash, revokedAt: null },\n data: { revokedAt: at },\n });\n // Lost the claim: write NOTHING. The zero-row update commits as the no-op\n // it is, so there is nothing to roll back.\n if (count !== 1) return false;\n // Same transaction as the claim, so a crash cannot leave a live parent AND\n // a live child either.\n await tx.oAuthRefreshToken.create({ data: successor });\n return true;\n });\n },\n async revokeLiveForClient(userEmail, clientId) {\n const prisma = await getPrisma();\n const { count } = await prisma.oAuthRefreshToken.updateMany({\n where: { userEmail, clientId, revokedAt: null },\n data: { revokedAt: new Date() },\n });\n return count;\n },\n };\n}\n\n/** The connection columns the account surface reads. */\nconst CONNECTION_SELECT = {\n oauthClientId: true,\n clientName: true,\n host: true,\n connectedAt: true,\n lastActiveAt: true,\n} as const;\n\nfunction connectionStore(getPrisma: McpOauthPrismaProvider): McpConnectionStore {\n return {\n async lastActiveAt(userId, oauthClientId) {\n const prisma = await getPrisma();\n const row = await prisma.mcpConnection.findUnique({\n where: { userId_oauthClientId: { userId, oauthClientId } },\n select: { lastActiveAt: true },\n });\n return row?.lastActiveAt ?? null;\n },\n async recordActivity({ userId, oauthClientId, clientName, host, at }) {\n const prisma = await getPrisma();\n await prisma.mcpConnection.upsert({\n where: { userId_oauthClientId: { userId, oauthClientId } },\n create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },\n // Never blank a known host on refresh — keep the existing attribution\n // when this grant cannot derive one.\n update: {\n clientName,\n lastActiveAt: at,\n revokedAt: null,\n ...(host ? { host } : {}),\n },\n });\n },\n async listActive(userId) {\n const prisma = await getPrisma();\n const rows = await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null },\n orderBy: { lastActiveAt: \"desc\" },\n select: { ...CONNECTION_SELECT },\n });\n return rows as unknown as StoredMcpConnection[];\n },\n revokeByHost: (userId, host) => revokeByHost(getPrisma, userId, host),\n announce: (userId, host) => announce(getPrisma, userId, host),\n };\n}\n\n/**\n * Disconnect one provider's connections — and ONLY that provider's.\n *\n * Every read and write is scoped by `userId`: a connection is per-user (an MCP\n * bearer is not tenant-scoped), so the user id IS the isolation here, and the\n * `id` list passed to the update comes from a query that already applied it.\n */\nasync function revokeByHost(\n getPrisma: McpOauthPrismaProvider,\n userId: string,\n host: string,\n): Promise<string[]> {\n const prisma = await getPrisma();\n const attributed = await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null, host },\n select: { id: true, oauthClientId: true },\n });\n // A legacy `host = null` row is claimed only when the provider has no row of\n // its own: pre-attribution connections must stay disconnectable, but a provider\n // that DID attribute can never revoke another assistant's row.\n const targets =\n attributed.length > 0\n ? attributed\n : await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null, host: null },\n select: { id: true, oauthClientId: true },\n });\n if (targets.length === 0) return [];\n await prisma.mcpConnection.updateMany({\n where: { id: { in: targets.map((row) => String(row.id)) } },\n data: { revokedAt: new Date() },\n });\n return targets.map((row) => String(row.oauthClientId));\n}\n\n/** A provider's self-report: refresh its own row, or claim the unattributed one. */\nasync function announce(\n getPrisma: McpOauthPrismaProvider,\n userId: string,\n host: string,\n): Promise<number> {\n const prisma = await getPrisma();\n const now = new Date();\n const refreshed = await prisma.mcpConnection.updateMany({\n where: { userId, revokedAt: null, host },\n data: { lastActiveAt: now, revokedAt: null },\n });\n if (refreshed.count > 0) return refreshed.count;\n\n // No row for this provider yet — attribute the just-connected one. Scoped by\n // user, so a self-report can never reach another account's connection.\n const candidate = await prisma.mcpConnection.findFirst({\n where: { userId, revokedAt: null, host: null },\n orderBy: { lastActiveAt: \"desc\" },\n select: { id: true },\n });\n if (!candidate) return 0;\n await prisma.mcpConnection.update({\n where: { id: candidate.id },\n data: { host, lastActiveAt: now, revokedAt: null },\n });\n return 1;\n}\n\n/** Every port, over one lazily-resolved Prisma client. */\nexport function createPrismaMcpStores(getPrisma: McpOauthPrismaProvider): McpOauthStores {\n return {\n clients: clientStore(getPrisma),\n refreshTokens: refreshTokenStore(getPrisma),\n connections: connectionStore(getPrisma),\n };\n}\n","import { providerForHostId, type AiProvider } from \"../guide\";\nimport type {\n McpConnectionStore,\n RefreshTokenStore,\n StoredMcpConnection,\n} from \"./stores\";\n\n/**\n * The account surface's connection OPERATIONS (12-48) — the half of the\n * `GET/DELETE /api/account/mcp-connections` endpoints that is contract rather\n * than host vocabulary.\n *\n * The ROUTE stays in the host on purpose: it mixes the host's session\n * resolution, its response envelope, its published plugin URLs and its logger,\n * and injecting all four here would make the config surface bigger than the\n * handler it replaces. What must NOT stay in each host is the disconnect's\n * both-halves rule, because getting it half right LOOKS right:\n *\n * `connections.revokeByHost` ends the connection rows and returns the OAuth\n * client ids behind them — and a host that stops there has revoked nothing that\n * matters. The assistant still holds a live refresh token for each of those\n * clients, rotates it on schedule, and the very next grant records fresh\n * activity: the card the user just disconnected lights green again on its own.\n * So the rule is one function: revoke the rows AND end every live refresh token\n * of each returned client, in the same call, with no way to import one half\n * without the other.\n *\n * Deliberately NOT invalidated here: the assistant's current ACCESS token.\n * Those are self-contained JWTs the server does not track; a just-disconnected\n * host keeps working for at most their TTL (15 minutes by default) and can then\n * obtain nothing further.\n */\n\n/** An active AI connection, narrowed for display. */\nexport interface AiConnectionSnapshot {\n oauthClientId: string;\n clientName: string | null;\n /** The provider this connection is attributed to (`null` = pre-attribution). */\n host: AiProvider | null;\n connectedAt: Date;\n lastActiveAt: Date;\n}\n\n/** The caller the operations act for — always the session's own user. */\nexport interface AiConnectionCaller {\n /** The host's user id — what `mcp_connections` rows are keyed by. */\n userId: string;\n /** The identity refresh tokens are bound to (the AS binds by email). */\n email: string;\n}\n\n/** What one disconnect actually ended, for the host's log and response. */\nexport interface AiDisconnectResult {\n /** OAuth client ids whose connection rows were revoked. */\n disconnectedClientIds: string[];\n /** Live refresh tokens ended across those clients — the half that cuts access. */\n revokedRefreshTokens: number;\n}\n\n/** Narrow a stored `host` string to a known provider, or `null`. */\nfunction asProvider(host: string | null): AiProvider | null {\n return host === null ? null : providerForHostId(host);\n}\n\n/**\n * A user's active connections, most-recently-active first, with the stored open\n * `host` string narrowed to the package's closed {@link AiProvider} union — the\n * store cannot know which assistants have screens, but the union is this\n * package's own vocabulary (`guide.ts`), so the narrowing lives beside it\n * rather than being re-derived in every host.\n */\nexport async function listAiConnections(\n connections: McpConnectionStore,\n userId: string,\n): Promise<AiConnectionSnapshot[]> {\n const rows: StoredMcpConnection[] = await connections.listActive(userId);\n return rows.map((row) => ({ ...row, host: asProvider(row.host) }));\n}\n\n/**\n * Disconnect one provider for this user — BOTH halves, atomically from the\n * caller's point of view (see the module doc for why one half alone is a\n * disconnect that undoes itself).\n *\n * Idempotent: disconnecting a provider that was never connected returns zero\n * counts rather than failing, so a double-click is harmless. Repeat calls also\n * report zero — `revokeLiveForClient` skips already-revoked tokens by contract.\n */\nexport async function disconnectAiHost(\n stores: { connections: McpConnectionStore; refreshTokens: RefreshTokenStore },\n caller: AiConnectionCaller,\n host: AiProvider,\n): Promise<AiDisconnectResult> {\n const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);\n const revoked = await Promise.all(\n disconnectedClientIds.map((clientId) =>\n stores.refreshTokens.revokeLiveForClient(caller.email, clientId),\n ),\n );\n return {\n disconnectedClientIds,\n revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0),\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.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": {
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
"types": "./dist/generate/index.d.ts",
|
|
29
29
|
"default": "./dist/generate/index.js"
|
|
30
30
|
},
|
|
31
|
+
"./manifest": {
|
|
32
|
+
"types": "./dist/manifest/index.d.ts",
|
|
33
|
+
"default": "./dist/manifest/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./manifest/server": {
|
|
36
|
+
"types": "./dist/manifest/server.d.ts",
|
|
37
|
+
"default": "./dist/manifest/server.js"
|
|
38
|
+
},
|
|
31
39
|
"./package.json": "./package.json"
|
|
32
40
|
},
|
|
33
41
|
"scripts": {
|
|
@@ -42,22 +50,35 @@
|
|
|
42
50
|
"prisma:sync:check": "node scripts/sync-mcp-schema.mjs --check"
|
|
43
51
|
},
|
|
44
52
|
"dependencies": {
|
|
45
|
-
"@12-apps/onboarding": "^2.
|
|
46
|
-
"@12-apps/rbac": "^4.
|
|
53
|
+
"@12-apps/onboarding": "^2.6.0",
|
|
54
|
+
"@12-apps/rbac": "^4.9.0",
|
|
47
55
|
"@12-apps/ui": "^6.9.0",
|
|
48
56
|
"@mui/icons-material": "^6.5.0",
|
|
49
57
|
"jose": "^6.1.3",
|
|
50
58
|
"react": "^19.2.0"
|
|
51
59
|
},
|
|
52
60
|
"peerDependencies": {
|
|
53
|
-
"
|
|
61
|
+
"@12-apps/wiring": ">=1.9.0",
|
|
54
62
|
"hono": ">=4.0.0",
|
|
63
|
+
"react": ">=19.0.0",
|
|
55
64
|
"zod": ">=4.0.0"
|
|
56
65
|
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@12-apps/wiring": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"hono": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"zod": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
57
77
|
"devDependencies": {
|
|
58
78
|
"@12-apps/eslint-config": "^1.21.1",
|
|
59
|
-
"@12-apps/i18n": "^1.
|
|
79
|
+
"@12-apps/i18n": "^1.1.0",
|
|
60
80
|
"@12-apps/typescript-config": "^1.20.1",
|
|
81
|
+
"@12-apps/wiring": "^1.13.0",
|
|
61
82
|
"@mui/material": "^6.5.0",
|
|
62
83
|
"@testing-library/react": "^16.1.0",
|
|
63
84
|
"@types/node": "^22.15.3",
|
|
@@ -72,6 +93,12 @@
|
|
|
72
93
|
"vitest": "^3.2.4",
|
|
73
94
|
"zod": "^4.3.5"
|
|
74
95
|
},
|
|
96
|
+
"wiring": {
|
|
97
|
+
"db": {
|
|
98
|
+
"partial": "prisma/mcp.prisma",
|
|
99
|
+
"migrations": "prisma/migrations"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
75
102
|
"engines": {
|
|
76
103
|
"node": ">=22.0.0"
|
|
77
104
|
},
|
|
@@ -102,13 +129,5 @@
|
|
|
102
129
|
"!**/*.stories.*",
|
|
103
130
|
"!**/*.test-story.*",
|
|
104
131
|
"!**/test-helpers.*"
|
|
105
|
-
]
|
|
106
|
-
"peerDependenciesMeta": {
|
|
107
|
-
"hono": {
|
|
108
|
-
"optional": true
|
|
109
|
-
},
|
|
110
|
-
"zod": {
|
|
111
|
-
"optional": true
|
|
112
|
-
}
|
|
113
|
-
}
|
|
132
|
+
]
|
|
114
133
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,16 @@ export { inlineSchemaRefs, UnsupportedSchemaError } from "./openapi/refs";
|
|
|
29
29
|
// here so that packages which own a domain can ship that domain's endpoints and
|
|
30
30
|
// a host can concatenate them — which requires all of them to mean the same
|
|
31
31
|
// thing by "an endpoint". See `openapi/endpoint.ts`.
|
|
32
|
-
export type { McpEndpoint, HttpMethod } from "./openapi/endpoint";
|
|
32
|
+
export type { McpEndpoint, HttpMethod, McpAnnotationDefaults } from "./openapi/endpoint";
|
|
33
|
+
/**
|
|
34
|
+
* Composing a tool's classification from a package's declared defaults and the
|
|
35
|
+
* host's own table — the host still wins every field it states, and a field
|
|
36
|
+
* neither side supplies is a refusal. See `./openapi/annotations`.
|
|
37
|
+
*/
|
|
38
|
+
export {
|
|
39
|
+
resolveToolAnnotations,
|
|
40
|
+
type ToolAnnotationOverrides,
|
|
41
|
+
} from "./openapi/annotations";
|
|
33
42
|
export type {
|
|
34
43
|
OpenApiDocument,
|
|
35
44
|
OpenApiOperation,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/mcp/manifest` — the SHARED wiring manifest.
|
|
3
|
+
*
|
|
4
|
+
* Identity, the Prisma contribution (the three tables behind the
|
|
5
|
+
* authorization server) and the runtime inventory: `http` on the server.
|
|
6
|
+
*
|
|
7
|
+
* ON THE `db` DECLARATION — the reason this manifest exists at all.
|
|
8
|
+
*
|
|
9
|
+
* This package ships `prisma/mcp.prisma` and a migration beside it, and until
|
|
10
|
+
* now nothing said so in a form a host assembler could read. The origin
|
|
11
|
+
* host's assembler discovers partials in two steps: a package that carries
|
|
12
|
+
* `"wiring": { "db": ... }` in its package.json is taken at its word, and a
|
|
13
|
+
* package that carries nothing falls back to a STRUCTURAL scan — every
|
|
14
|
+
* `prisma/*.prisma` under the package root is treated as a partial. So three
|
|
15
|
+
* tables reach somebody's database because a `readdir` found them, not
|
|
16
|
+
* because this package said they should. `@12-apps/notifications`' manifest
|
|
17
|
+
* closed exactly this gap for its four models and the anti-pattern audit
|
|
18
|
+
* names it directly; declaring changes no assembler behaviour (the
|
|
19
|
+
* declaration is read where the scan used to run) and closes the one case
|
|
20
|
+
* where composition was happening by accident.
|
|
21
|
+
*
|
|
22
|
+
* The mirror is what makes the declaration reachable: host assemblers are
|
|
23
|
+
* plain Node reading `node_modules` and cannot execute this TypeScript, so
|
|
24
|
+
* the contribution is repeated under `package.json` `"wiring": { "db": … }`
|
|
25
|
+
* and `assertDbMirror` pins the two together in this package's own test run.
|
|
26
|
+
*
|
|
27
|
+
* `composed`, not `isolated`, and the choice is forced. An isolated stack
|
|
28
|
+
* needs models carrying no relation into host tables — true of the three
|
|
29
|
+
* here as SHIPPED (`user_id` and `user_email` are deliberately by-value
|
|
30
|
+
* scalars, see the partial's header) — but the host is invited to add the FK
|
|
31
|
+
* in its own migration, and the origin host's is `ON DELETE CASCADE`. A
|
|
32
|
+
* package cannot declare isolation for models whose adopters relate them
|
|
33
|
+
* into their own account tables.
|
|
34
|
+
*
|
|
35
|
+
* ## THE NARROWINGS, each deliberate
|
|
36
|
+
*
|
|
37
|
+
* - **No `mcp` capability.** This package IS the MCP runtime — the
|
|
38
|
+
* OpenAPI→tools generator, the registry, the JSON-RPC transport, the
|
|
39
|
+
* coverage gate. It advertises no tools of its own, and a manifest that
|
|
40
|
+
* declared any would be the runtime describing itself to itself.
|
|
41
|
+
* - **No `permissions`.** Authorization here is the OAuth scope set
|
|
42
|
+
* (`MCP_SUPPORTED_SCOPES`) plus whatever the host's own RBAC says about
|
|
43
|
+
* the proxied endpoint — the point of bearer passthrough is that an agent
|
|
44
|
+
* inherits the caller's permissions rather than holding its own. There is
|
|
45
|
+
* no id for this package to contribute.
|
|
46
|
+
* - **No `web` inventory**, though `./react` ships the whole AI-connect
|
|
47
|
+
* onboarding flow. A `surface` contribution is a `createWeb*` FACTORY —
|
|
48
|
+
* one config object in, an object of component types out, memoised once by
|
|
49
|
+
* the binder. `./react` has no such factory: it exports components a host
|
|
50
|
+
* mounts with its own props (`AiIntegrationOnboarding` takes the store,
|
|
51
|
+
* the endpoint URL and the live connection at the call site). Inventing a
|
|
52
|
+
* factory here to have something to declare would freeze a props table
|
|
53
|
+
* three hosts pass differently, which is the opposite of what a surface
|
|
54
|
+
* contribution is for. When the flow grows a real bound surface, the
|
|
55
|
+
* inventory grows with it.
|
|
56
|
+
* - **No `env`.** The signing-key variables (`DEFAULT_SIGNING_KEY_ENV`,
|
|
57
|
+
* `DEFAULT_SIGNING_KEY_ID_ENV`) and `trustedOriginsFromEnv` are NAMES this
|
|
58
|
+
* package exports for a host to read `process.env` with; the package reads
|
|
59
|
+
* nothing itself, and the names are overridable per call. Declaring them
|
|
60
|
+
* would oblige a host to answer for variables it may legitimately have
|
|
61
|
+
* spelled differently.
|
|
62
|
+
* - **No `e2e`.** This package packages no journeys.
|
|
63
|
+
* - **No `jobs`.** Nothing here sweeps: authorization codes are stateless
|
|
64
|
+
* signed blobs (the partial's header says so — there is no `oauth_codes`
|
|
65
|
+
* table and nothing to expire), and refresh-token revocation happens on
|
|
66
|
+
* the rotation path rather than on a clock.
|
|
67
|
+
*
|
|
68
|
+
* `@12-apps/wiring` is a TYPE-ONLY devDependency (the report-builder move):
|
|
69
|
+
* the manifest is a plain `satisfies`-checked value, and the producer
|
|
70
|
+
* factories' runtime assertions run in this package's own test suite.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import type { PackageManifest } from "@12-apps/wiring";
|
|
74
|
+
|
|
75
|
+
export const mcpManifest = {
|
|
76
|
+
name: "@12-apps/mcp",
|
|
77
|
+
contract: 1,
|
|
78
|
+
db: { partial: "prisma/mcp.prisma", migrations: "prisma/migrations" },
|
|
79
|
+
/**
|
|
80
|
+
* A refused token grant, an unresolvable signing key or a rejected
|
|
81
|
+
* redirect URI files under `mcp` rather than under whichever host mounted
|
|
82
|
+
* the authorization server. Mandatory for runtime manifests since wiring
|
|
83
|
+
* 1.3.0, and this is the surface that most needs it: every failure here is
|
|
84
|
+
* a caller who cannot connect, reported to them as an opaque OAuth error
|
|
85
|
+
* code by specification.
|
|
86
|
+
*/
|
|
87
|
+
observability: { namespace: "mcp" },
|
|
88
|
+
server: ["http"],
|
|
89
|
+
} as const satisfies PackageManifest;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/mcp/manifest/server` — the server capabilities.
|
|
3
|
+
*
|
|
4
|
+
* `http.create` wraps `createApiMcpOauth` in a WIRE VIEW, and the reason is
|
|
5
|
+
* the shape of an OAuth answer. `McpOauthRoute.handle` takes a Fetch
|
|
6
|
+
* `Request` and returns a Fetch `Response` — not a `{ status, body }` pair —
|
|
7
|
+
* because every endpoint here answers something the JSON pair cannot say: a
|
|
8
|
+
* 302 whose `Location` IS the payload, a form-encoded exchange answering
|
|
9
|
+
* RFC 6749 §5.1/§5.2 with its own cache headers, a JWKS with a
|
|
10
|
+
* `public, max-age=300`, an RFC 8414/9728 document. `create-api-mcp-oauth`'s
|
|
11
|
+
* own docstring says a wrapper would only break those. So the view answers
|
|
12
|
+
* the contract's RAW half (`{ response }`, wiring 1.9.0), which exists for
|
|
13
|
+
* exactly this, and the descriptors stay untouched.
|
|
14
|
+
*
|
|
15
|
+
* ## THE RAW REQUEST IS REQUIRED, and the view says so out loud
|
|
16
|
+
*
|
|
17
|
+
* The contract obliges an adapter to fill `params`/`query`/`body` and lets it
|
|
18
|
+
* fill `request`. Every handler here needs the whole request — the exact URL
|
|
19
|
+
* (redirect_uri echo, PKCE parameters, the `Host` an issuer is derived
|
|
20
|
+
* from), the form body byte-for-byte, the cookie header the session is read
|
|
21
|
+
* off. So a missing `request` is refused loudly at the first call rather
|
|
22
|
+
* than silently producing an authorization server that mints codes for the
|
|
23
|
+
* wrong origin. `@12-apps/storage`'s view takes the same posture for the
|
|
24
|
+
* same reason.
|
|
25
|
+
*
|
|
26
|
+
* ## THE MOUNT IS THE ORIGIN ROOT
|
|
27
|
+
*
|
|
28
|
+
* `McpOauthRoute.path` is absolute from the origin root — `.well-known/*`
|
|
29
|
+
* cannot live under a prefix (RFC 8615), and a connector reads those
|
|
30
|
+
* documents before it has ever spoken to us. The consumer joins
|
|
31
|
+
* `mountPath + path`, so the ONLY correct binding is `mountPath: "/"`; the
|
|
32
|
+
* paths themselves stay configurable per host through `config.paths`, which
|
|
33
|
+
* is where a host that serves `authorize` somewhere else says so. Binding
|
|
34
|
+
* this surface under a prefix would move the discovery documents off the
|
|
35
|
+
* two URLs the specification reserves, and the symptom is a connector that
|
|
36
|
+
* cannot find the authorization server at all.
|
|
37
|
+
*
|
|
38
|
+
* ## EVERY ROUTE IS `public`, and that is a decision
|
|
39
|
+
*
|
|
40
|
+
* Not "unguarded": these six ARE the authentication, so a host RBAC gate in
|
|
41
|
+
* front of them would demand a token from the endpoint that issues tokens.
|
|
42
|
+
* `authorize` reads the host's cookie session itself (`config.resolveSession`)
|
|
43
|
+
* and sends an anonymous caller through the host's sign-in flow; `token` and
|
|
44
|
+
* `register` authenticate the CLIENT per RFC; the three documents are public
|
|
45
|
+
* by specification. `public` is also the contract's only kind that forbids a
|
|
46
|
+
* `permission` while allowing the routes to be reached anonymously — which
|
|
47
|
+
* is precisely the property that has to hold here.
|
|
48
|
+
*
|
|
49
|
+
* `@12-apps/wiring` is a TYPE-ONLY devDependency — see `./index`.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type { AnyServerManifest, WireRequest, WireRouteAnswer } from "@12-apps/wiring";
|
|
53
|
+
|
|
54
|
+
import {
|
|
55
|
+
createApiMcpOauth,
|
|
56
|
+
type ApiMcpOauth,
|
|
57
|
+
type McpOauthConfig,
|
|
58
|
+
type McpOauthRoute,
|
|
59
|
+
} from "../oauth";
|
|
60
|
+
|
|
61
|
+
/** One `McpOauthRoute` as the wiring contract reads it. */
|
|
62
|
+
function asWireRoute(route: McpOauthRoute): {
|
|
63
|
+
method: McpOauthRoute["method"];
|
|
64
|
+
path: string;
|
|
65
|
+
kind: "public";
|
|
66
|
+
handle(request: WireRequest): Promise<WireRouteAnswer>;
|
|
67
|
+
} {
|
|
68
|
+
return {
|
|
69
|
+
method: route.method,
|
|
70
|
+
path: route.path,
|
|
71
|
+
kind: "public",
|
|
72
|
+
handle: async (request) => {
|
|
73
|
+
if (!request.request) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"@12-apps/mcp/oauth needs the raw request — bind an adapter that forwards it.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return { response: await route.handle(request.request) };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `createApiMcpOauth`, its routes re-shaped for the aggregate. */
|
|
84
|
+
export function createWireApiMcpOauth(
|
|
85
|
+
config: McpOauthConfig,
|
|
86
|
+
): Omit<ApiMcpOauth, "routes"> & { routes: ReturnType<typeof asWireRoute>[] } {
|
|
87
|
+
const api = createApiMcpOauth(config);
|
|
88
|
+
return { ...api, routes: api.routes.map(asWireRoute) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const mcpServerManifest = {
|
|
92
|
+
name: "@12-apps/mcp",
|
|
93
|
+
http: { create: createWireApiMcpOauth },
|
|
94
|
+
} as const satisfies AnyServerManifest;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composing a tool's behavior classification out of two sources.
|
|
3
|
+
*
|
|
4
|
+
* The rule the host's gate enforces is unchanged: every served tool ends with
|
|
5
|
+
* a COMPLETE `ToolAnnotations` — a title and all three hints — and a tool that
|
|
6
|
+
* ends unclassified fails `mcp:lint`. ChatGPT App review treats a missing hint
|
|
7
|
+
* as a blocker, and the Anthropic connector directory derives auto-permissions
|
|
8
|
+
* from `readOnlyHint`/`destructiveHint`, so there is no defensible default for
|
|
9
|
+
* "we did not say".
|
|
10
|
+
*
|
|
11
|
+
* What this adds is where the answer may COME FROM. A package that declares
|
|
12
|
+
* `getSupplierVersions` knows it reads and does not destroy; the host cannot
|
|
13
|
+
* know that without reading the package's source, so it restated the
|
|
14
|
+
* classification by hand — one line per tool, per collection, wrong the moment
|
|
15
|
+
* the package changed a verb. Now the package can assert what it knows and the
|
|
16
|
+
* host's table becomes what it should always have been: OVERRIDES, plus the
|
|
17
|
+
* tools the host itself owns.
|
|
18
|
+
*
|
|
19
|
+
* ## Precedence, and why it runs this way
|
|
20
|
+
*
|
|
21
|
+
* The HOST wins every field it states. A package's claim is a default, not a
|
|
22
|
+
* fact about the host's deployment: the same endpoint can be read-only in one
|
|
23
|
+
* app and reach an external service in another (a host that proxies its
|
|
24
|
+
* catalog reads through a vendor), and the host is the only party that knows.
|
|
25
|
+
* Inverting this would make a package version bump silently re-classify a tool
|
|
26
|
+
* an operator had already audited — the exact thing an audited classification
|
|
27
|
+
* exists to prevent.
|
|
28
|
+
*
|
|
29
|
+
* ## What it refuses
|
|
30
|
+
*
|
|
31
|
+
* A field neither side supplies. `resolveToolAnnotations` throws naming the
|
|
32
|
+
* tool and the missing fields, which keeps the completeness property a
|
|
33
|
+
* REFUSAL rather than a lint pass over a table that quietly grew a gap. The
|
|
34
|
+
* host's own gate can keep its message; this one fires first and says the same
|
|
35
|
+
* thing in the same terms.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ToolAnnotations } from "../types";
|
|
39
|
+
import type { McpAnnotationDefaults } from "./endpoint";
|
|
40
|
+
|
|
41
|
+
/** The host's half — whatever it chose to state, per tool. */
|
|
42
|
+
export type ToolAnnotationOverrides = Partial<ToolAnnotations>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge a package's declared defaults under a host's overrides.
|
|
46
|
+
*
|
|
47
|
+
* @param name the tool id, for the refusal message
|
|
48
|
+
* @param defaults what the package asserted (`McpEndpoint.annotations`)
|
|
49
|
+
* @param overrides what the host's own table says; wins every field it states
|
|
50
|
+
*/
|
|
51
|
+
/** A title only counts when it has something in it. */
|
|
52
|
+
function titleOf(candidate: string | undefined): string | undefined {
|
|
53
|
+
return typeof candidate === "string" && candidate.trim() !== "" ? candidate : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge a package's declared defaults under a host's overrides.
|
|
58
|
+
*
|
|
59
|
+
* The four fields are resolved into one record and checked generically rather
|
|
60
|
+
* than branch by branch — which keeps the "host wins, and `false` is an
|
|
61
|
+
* answer" rule stated exactly once per field instead of once per field per
|
|
62
|
+
* check.
|
|
63
|
+
*
|
|
64
|
+
* `??` and not `||` throughout, and that is the trap the whole merge turns on:
|
|
65
|
+
* `false` is a real classification — "this tool does not destroy" — and must
|
|
66
|
+
* not fall through to the package's answer.
|
|
67
|
+
*
|
|
68
|
+
* @param name the tool id, for the refusal message
|
|
69
|
+
* @param defaults what the package asserted (`McpEndpoint.annotations`)
|
|
70
|
+
* @param overrides what the host's own table says; wins every field it states
|
|
71
|
+
*/
|
|
72
|
+
export function resolveToolAnnotations(
|
|
73
|
+
name: string,
|
|
74
|
+
defaults: McpAnnotationDefaults | undefined,
|
|
75
|
+
overrides: ToolAnnotationOverrides | undefined,
|
|
76
|
+
): ToolAnnotations {
|
|
77
|
+
const host = overrides ?? {};
|
|
78
|
+
const declared = defaults ?? {};
|
|
79
|
+
const resolved: Partial<ToolAnnotations> = {
|
|
80
|
+
title: titleOf(host.title ?? declared.title),
|
|
81
|
+
readOnlyHint: host.readOnlyHint ?? declared.readOnly,
|
|
82
|
+
openWorldHint: host.openWorldHint ?? declared.openWorld,
|
|
83
|
+
destructiveHint: host.destructiveHint ?? declared.destructive,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const missing = REQUIRED_FIELDS.filter((field) => resolved[field] === undefined);
|
|
87
|
+
if (missing.length > 0) refuse(name, missing);
|
|
88
|
+
return resolved as ToolAnnotations;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every field a served tool must end with — the completeness property itself. */
|
|
92
|
+
const REQUIRED_FIELDS = [
|
|
93
|
+
"title",
|
|
94
|
+
"readOnlyHint",
|
|
95
|
+
"openWorldHint",
|
|
96
|
+
"destructiveHint",
|
|
97
|
+
] as const satisfies readonly (keyof ToolAnnotations)[];
|
|
98
|
+
|
|
99
|
+
function refuse(name: string, missing: readonly string[]): never {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`MCP tool "${name}" ends unclassified: neither the package nor the host supplied ` +
|
|
102
|
+
`${missing.join(", ")}. Every served tool needs a complete classification — a missing ` +
|
|
103
|
+
`hint blocks ChatGPT App review, and the connector directory derives auto-permissions ` +
|
|
104
|
+
`from readOnlyHint/destructiveHint.`,
|
|
105
|
+
);
|
|
106
|
+
}
|