@softure-ai/mcp-access 0.2.9 → 0.4.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/CHANGELOG.md +66 -0
- package/README.md +241 -11
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +2 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +48 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +88 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +5 -2
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/operation-state.d.ts +58 -0
- package/dist/jobs/operation-state.d.ts.map +1 -0
- package/dist/jobs/operation-state.js +68 -0
- package/dist/jobs/operation-state.js.map +1 -0
- package/dist/jobs/operation-tasks.d.ts +68 -0
- package/dist/jobs/operation-tasks.d.ts.map +1 -0
- package/dist/jobs/operation-tasks.js +163 -0
- package/dist/jobs/operation-tasks.js.map +1 -0
- package/dist/jobs/operations.d.ts +89 -0
- package/dist/jobs/operations.d.ts.map +1 -0
- package/dist/jobs/operations.js +131 -0
- package/dist/jobs/operations.js.map +1 -0
- package/dist/messages/en.d.ts +55 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +55 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +54 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/next/issue-token.d.ts +11 -2
- package/dist/next/issue-token.d.ts.map +1 -1
- package/dist/next/issue-token.js +56 -7
- package/dist/next/issue-token.js.map +1 -1
- package/dist/next/token-manager-props.d.ts +3 -2
- package/dist/next/token-manager-props.d.ts.map +1 -1
- package/dist/next/token-manager-props.js +12 -2
- package/dist/next/token-manager-props.js.map +1 -1
- package/dist/options.d.ts +47 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +53 -0
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +214 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +21 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/api-tokens.d.ts +64 -0
- package/dist/server/api-tokens.d.ts.map +1 -0
- package/dist/server/api-tokens.js +176 -0
- package/dist/server/api-tokens.js.map +1 -0
- package/dist/server/confirmation.d.ts +69 -0
- package/dist/server/confirmation.d.ts.map +1 -0
- package/dist/server/confirmation.js +220 -0
- package/dist/server/confirmation.js.map +1 -0
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +3 -1
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/privacy.d.ts +4 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +5 -1
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/prune.d.ts +5 -1
- package/dist/server/prune.d.ts.map +1 -1
- package/dist/server/prune.js +14 -4
- package/dist/server/prune.js.map +1 -1
- package/dist/server/strict-input.d.ts +3 -1
- package/dist/server/strict-input.d.ts.map +1 -1
- package/dist/server/strict-input.js +54 -8
- package/dist/server/strict-input.js.map +1 -1
- package/dist/server/tokens.d.ts +46 -11
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +46 -24
- package/dist/server/tokens.js.map +1 -1
- package/dist/server/tools.d.ts +8 -0
- package/dist/server/tools.d.ts.map +1 -1
- package/dist/server/tools.js +1 -0
- package/dist/server/tools.js.map +1 -1
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +8 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +18 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +28 -3
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0004_create_used_confirmations.sql +13 -0
- package/migrations/0005_add_api_tokens.sql +15 -0
- package/migrations/0006_create_operation_tasks.sql +18 -0
- package/module.json +9 -3
- package/package.json +7 -2
- package/src/cli/run.ts +2 -2
- package/src/contract.ts +60 -2
- package/src/index.ts +31 -3
- package/src/jobs/index.ts +43 -2
- package/src/jobs/operation-state.ts +119 -0
- package/src/jobs/operation-tasks.ts +234 -0
- package/src/jobs/operations.ts +258 -0
- package/src/messages/en.ts +56 -0
- package/src/next/issue-token.ts +64 -9
- package/src/next/token-manager-props.ts +15 -4
- package/src/options.ts +62 -0
- package/src/schema.ts +23 -3
- package/src/server/api-tokens.ts +224 -0
- package/src/server/confirmation.ts +316 -0
- package/src/server/health.ts +3 -1
- package/src/server/index.ts +32 -2
- package/src/server/privacy.ts +9 -1
- package/src/server/prune.ts +17 -4
- package/src/server/strict-input.ts +62 -13
- package/src/server/tokens.ts +73 -27
- package/src/server/tools.ts +9 -0
- package/src/tokens/index.ts +22 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/token-manager.tsx +140 -3
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Personal tokens for an app's plain HTTP routes (#961): a phone Shortcut, an automation tool or a
|
|
2
|
+
// script sends `Authorization: Bearer <prefix>…` and the route calls `requireApiToken`. They share the
|
|
3
|
+
// table, hashing, expiry, limit, privacy and prune with the MCP tokens (`tokens.ts`); the `kind`
|
|
4
|
+
// column keeps the two doors apart. Nothing here imports the MCP SDK: `@softure-ai/mcp-access/tokens`
|
|
5
|
+
// is for an app that has no MCP server.
|
|
6
|
+
import { err, errorLogLabel, getModule, ok, type Err, type Ok } from "@softure-ai/core";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
|
|
9
|
+
import type { ApiTokenIdentity, ApiTokenRefusal } from "../contract.js";
|
|
10
|
+
import { MAX_PRESENTED_TOKEN_LENGTH, type ApiTokenOptions } from "../options.js";
|
|
11
|
+
import { getMcpAccessOptions } from "./options.js";
|
|
12
|
+
import { findValidToken, hashAccessToken, insertToken, type McpAccessContext } from "./tokens.js";
|
|
13
|
+
import { isWritingEnabled } from "./writes-switch.js";
|
|
14
|
+
|
|
15
|
+
/** The bucket counted per client address before the token is looked up. */
|
|
16
|
+
export const API_RATE_LIMIT_BUCKET = "api";
|
|
17
|
+
/** The bucket counted per verified token. */
|
|
18
|
+
export const API_TOKEN_RATE_LIMIT_BUCKET = "api-token";
|
|
19
|
+
|
|
20
|
+
const BEARER_PREFIX = /^Bearer[ \t]+/i;
|
|
21
|
+
const NO_STORE = "no-store";
|
|
22
|
+
|
|
23
|
+
/** The app's API token settings, or null while `apiTokens` is unset. */
|
|
24
|
+
export function getApiTokenOptions(ctx: Pick<McpAccessContext, "config">): ApiTokenOptions | null {
|
|
25
|
+
return getMcpAccessOptions(ctx.config).apiTokens ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a presented value has the shape of one of the app's API tokens: its prefix and 32 base64url bytes. */
|
|
29
|
+
export function isApiTokenShape(options: ApiTokenOptions, token: string): boolean {
|
|
30
|
+
if (token.length > MAX_PRESENTED_TOKEN_LENGTH || !token.startsWith(options.prefix)) return false;
|
|
31
|
+
return /^[A-Za-z0-9_-]{43}$/.test(token.slice(options.prefix.length));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A new API token: the app's prefix and 32 random bytes. The stored sha256 covers the random part
|
|
36
|
+
* alone (the secret); the prefix is a label that the shape check requires before any lookup.
|
|
37
|
+
*/
|
|
38
|
+
function createApiSecret(prefix: string): { readonly secret: string; readonly secretHash: string } {
|
|
39
|
+
const random = randomBytes(32).toString("base64url");
|
|
40
|
+
return { secret: `${prefix}${random}`, secretHash: hashAccessToken(random) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IssueApiTokenInput {
|
|
44
|
+
/** The auth user id of the owner, from the session. */
|
|
45
|
+
readonly userId: string;
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** Scope names from the form: any values; unknown ones are refused. */
|
|
48
|
+
readonly scopes: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IssuedApiToken {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
/** The plaintext: shown to the owner once, stored nowhere. */
|
|
54
|
+
readonly token: string;
|
|
55
|
+
readonly name: string;
|
|
56
|
+
/** The scopes granted, in the order the app declares them. */
|
|
57
|
+
readonly scopes: readonly string[];
|
|
58
|
+
readonly expiresAt: Date;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type IssueApiTokenResult =
|
|
62
|
+
| Ok<IssuedApiToken>
|
|
63
|
+
| Err<
|
|
64
|
+
| "mcp-access.name_required"
|
|
65
|
+
| "mcp-access.name_too_long"
|
|
66
|
+
| "mcp-access.token_limit_reached"
|
|
67
|
+
| "mcp-access.api_disabled"
|
|
68
|
+
| "mcp-access.scope_required"
|
|
69
|
+
| "mcp-access.scope_unknown"
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Issues an API token with the asked scopes. A scope the app does not declare is refused; a write
|
|
74
|
+
* scope is granted only while `allowWrites` is on (left out otherwise), and a token needs at least
|
|
75
|
+
* one scope left. Shares the per-account limit and lock with MCP tokens. Database failures propagate.
|
|
76
|
+
*/
|
|
77
|
+
export async function issueApiToken(ctx: McpAccessContext, input: IssueApiTokenInput): Promise<IssueApiTokenResult> {
|
|
78
|
+
const apiOptions = getApiTokenOptions(ctx);
|
|
79
|
+
if (apiOptions === null) return err("mcp-access.api_disabled");
|
|
80
|
+
const asked = new Set(input.scopes);
|
|
81
|
+
const declared = new Set(apiOptions.scopes.map((scope) => scope.name));
|
|
82
|
+
if ([...asked].some((name) => !declared.has(name))) return err("mcp-access.scope_unknown");
|
|
83
|
+
|
|
84
|
+
const { allowWrites } = getMcpAccessOptions(ctx.config);
|
|
85
|
+
const granted = apiOptions.scopes.filter((scope) => asked.has(scope.name) && (scope.access === "read" || allowWrites));
|
|
86
|
+
if (granted.length === 0) return err("mcp-access.scope_required");
|
|
87
|
+
|
|
88
|
+
const scopes = granted.map((scope) => scope.name);
|
|
89
|
+
const canWrite = granted.some((scope) => scope.access === "write");
|
|
90
|
+
const result = await insertToken(ctx, { userId: input.userId, name: input.name, kind: "api", canWrite, scopes, secret: createApiSecret(apiOptions.prefix) });
|
|
91
|
+
if (!result.ok) return result;
|
|
92
|
+
const { id, token, name, expiresAt } = result.value;
|
|
93
|
+
return ok({ id, token, name, scopes, expiresAt });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The identity behind a valid API token. */
|
|
97
|
+
export interface VerifiedApiToken extends ApiTokenIdentity {
|
|
98
|
+
readonly expiresAt: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The identity behind an API token, or null for anything unknown, revoked, expired, an MCP token or
|
|
103
|
+
* any value without the app's prefix. Write scopes count only while writing is enabled (`allowWrites`
|
|
104
|
+
* and the `mcp-access.writes_enabled` switch). Records the use like an MCP token. Database failures propagate.
|
|
105
|
+
*/
|
|
106
|
+
export async function verifyApiToken(ctx: McpAccessContext, token: string): Promise<VerifiedApiToken | null> {
|
|
107
|
+
return (await checkApiToken(ctx, token))?.verified ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A verified token and the write scopes it holds that the writes switch keeps from it now. */
|
|
111
|
+
async function checkApiToken(ctx: McpAccessContext, token: string): Promise<{ verified: VerifiedApiToken; withheld: readonly string[] } | null> {
|
|
112
|
+
const apiOptions = getApiTokenOptions(ctx);
|
|
113
|
+
if (apiOptions === null || !isApiTokenShape(apiOptions, token)) return null;
|
|
114
|
+
const row = await findValidToken(ctx, hashAccessToken(token.slice(apiOptions.prefix.length)), "api");
|
|
115
|
+
if (row === null) return null;
|
|
116
|
+
const isWriteAllowed = await isWritingEnabled(ctx);
|
|
117
|
+
const writeScopes = new Set(apiOptions.scopes.filter((scope) => scope.access === "write").map((scope) => scope.name));
|
|
118
|
+
const withheld = isWriteAllowed ? [] : row.scopes.filter((scope) => writeScopes.has(scope));
|
|
119
|
+
return {
|
|
120
|
+
verified: { userId: row.userId, tokenId: row.id, scopes: row.scopes.filter((scope) => !withheld.includes(scope)), expiresAt: row.expiresAt },
|
|
121
|
+
withheld,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RequireApiTokenOptions {
|
|
126
|
+
/** Every scope the route needs; each must be one the app declares in `apiTokens.scopes`. */
|
|
127
|
+
readonly scopes: readonly string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Checks a route's request for a valid API token with every scope in `scopes`, in this order:
|
|
132
|
+
* the client is identified, its request counted per address (before any lookup, so a flood of
|
|
133
|
+
* guesses costs nothing), the `Authorization: Bearer` token verified, the request counted per token,
|
|
134
|
+
* the scopes checked. Expected refusals are values, for `apiTokenErrorResponse`; a failed count or
|
|
135
|
+
* lookup is logged and answered `mcp-access.api_unavailable`. A scope the app does not declare, or a
|
|
136
|
+
* missing bucket, is a setup bug and throws.
|
|
137
|
+
*/
|
|
138
|
+
export async function requireApiToken(
|
|
139
|
+
ctx: McpAccessContext,
|
|
140
|
+
request: Request,
|
|
141
|
+
{ scopes: required }: RequireApiTokenOptions,
|
|
142
|
+
): Promise<Ok<ApiTokenIdentity> | ApiTokenRefusal> {
|
|
143
|
+
const apiOptions = getApiTokenOptions(ctx);
|
|
144
|
+
if (apiOptions === null) {
|
|
145
|
+
throw new Error("@softure-ai/mcp-access: requireApiToken needs apiTokens in mcpAccess({ ... })");
|
|
146
|
+
}
|
|
147
|
+
assertDeclaredScopes(apiOptions, required);
|
|
148
|
+
assertRateLimitBuckets(ctx);
|
|
149
|
+
|
|
150
|
+
const client = identifyClient(ctx, request.headers);
|
|
151
|
+
if (!client.ok) return client;
|
|
152
|
+
try {
|
|
153
|
+
const byAddress = await consumeRateLimit(ctx, { bucket: API_RATE_LIMIT_BUCKET, key: client.value });
|
|
154
|
+
if (!byAddress.ok) return { ...err("security.rate_limited"), retryAfterSeconds: byAddress.retryAfterSeconds };
|
|
155
|
+
|
|
156
|
+
const header = request.headers.get("authorization");
|
|
157
|
+
if (header === null || !BEARER_PREFIX.test(header)) return err("mcp-access.api_token_missing");
|
|
158
|
+
const checked = await checkApiToken(ctx, header.replace(BEARER_PREFIX, "").trim());
|
|
159
|
+
if (checked === null) return err("mcp-access.api_token_invalid");
|
|
160
|
+
const { verified, withheld } = checked;
|
|
161
|
+
|
|
162
|
+
const byToken = await consumeRateLimit(ctx, { bucket: API_TOKEN_RATE_LIMIT_BUCKET, key: `token:${verified.tokenId}` });
|
|
163
|
+
if (!byToken.ok) return { ...err("security.rate_limited"), retryAfterSeconds: byToken.retryAfterSeconds };
|
|
164
|
+
|
|
165
|
+
const missingScopes = required.filter((scope) => !verified.scopes.includes(scope));
|
|
166
|
+
if (missingScopes.length > 0) {
|
|
167
|
+
// Write scopes the token holds while writing is off: say so, the token itself is fine.
|
|
168
|
+
const code = missingScopes.every((scope) => withheld.includes(scope)) ? "mcp-access.api_writes_disabled" : "mcp-access.api_scope_missing";
|
|
169
|
+
return { ...err(code), missingScopes };
|
|
170
|
+
}
|
|
171
|
+
return ok({ userId: verified.userId, tokenId: verified.tokenId, scopes: verified.scopes });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// The token is never logged; the operation and the failure label are.
|
|
174
|
+
console.error(`@softure-ai/mcp-access: checking an API token failed: ${errorLogLabel(error)}`);
|
|
175
|
+
return err("mcp-access.api_unavailable");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const STATUS_BY_CODE: Readonly<Record<ApiTokenRefusal["error"], number>> = {
|
|
180
|
+
"security.client_unidentified": 400,
|
|
181
|
+
"mcp-access.api_token_missing": 401,
|
|
182
|
+
"mcp-access.api_token_invalid": 401,
|
|
183
|
+
"mcp-access.api_scope_missing": 403,
|
|
184
|
+
"mcp-access.api_writes_disabled": 403,
|
|
185
|
+
"security.rate_limited": 429,
|
|
186
|
+
"mcp-access.api_unavailable": 503,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The HTTP answer to a refusal of `requireApiToken`: JSON `{ error }` (the code without its module), with
|
|
191
|
+
* `WWW-Authenticate` on 401 and 403 (RFC 6750 §3), `Retry-After` on 429, and never cached.
|
|
192
|
+
*/
|
|
193
|
+
export function apiTokenErrorResponse(refusal: ApiTokenRefusal): Response {
|
|
194
|
+
const status = STATUS_BY_CODE[refusal.error];
|
|
195
|
+
const error = refusal.error.slice(refusal.error.indexOf(".") + 1);
|
|
196
|
+
const headers = new Headers({ "cache-control": NO_STORE });
|
|
197
|
+
if (status === 401) {
|
|
198
|
+
headers.set("www-authenticate", refusal.error === "mcp-access.api_token_missing" ? "Bearer" : 'Bearer error="invalid_token"');
|
|
199
|
+
}
|
|
200
|
+
if ("missingScopes" in refusal) {
|
|
201
|
+
headers.set("www-authenticate", `Bearer error="insufficient_scope", scope="${refusal.missingScopes.join(" ")}"`);
|
|
202
|
+
return Response.json({ error, missingScopes: refusal.missingScopes }, { status, headers });
|
|
203
|
+
}
|
|
204
|
+
if ("retryAfterSeconds" in refusal) headers.set("retry-after", String(refusal.retryAfterSeconds));
|
|
205
|
+
return Response.json({ error }, { status, headers });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertDeclaredScopes(apiOptions: ApiTokenOptions, required: readonly string[]): void {
|
|
209
|
+
const declared = new Set(apiOptions.scopes.map((scope) => scope.name));
|
|
210
|
+
const unknown = required.filter((scope) => !declared.has(scope));
|
|
211
|
+
if (unknown.length > 0) {
|
|
212
|
+
throw new Error(`@softure-ai/mcp-access: requireApiToken asks for scopes apiTokens.scopes does not declare: ${unknown.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A missing bucket is a setup bug: thrown, so it reaches the log by name instead of a quiet 503. */
|
|
217
|
+
function assertRateLimitBuckets(ctx: McpAccessContext): void {
|
|
218
|
+
const options = getModule(ctx.config, "security")?.options as { buckets?: Record<string, unknown> } | undefined;
|
|
219
|
+
for (const bucket of [API_RATE_LIMIT_BUCKET, API_TOKEN_RATE_LIMIT_BUCKET]) {
|
|
220
|
+
if (options?.buckets?.[bucket] === undefined) {
|
|
221
|
+
throw new Error(`@softure-ai/mcp-access: the security module has no "${bucket}" bucket; spread API_TOKEN_RATE_LIMIT_BUCKETS into security({ buckets })`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Confirmed tool calls: the `--commit` rule over MCP for a tool that spends money or cannot be undone. A call
|
|
2
|
+
// without `confirmation` only runs the estimate and answers it with a confirmation token; the assistant shows the
|
|
3
|
+
// estimate, the person agrees in the chat, and a second call with the same arguments and the token does the work.
|
|
4
|
+
//
|
|
5
|
+
// server.registerTool("render_film", { inputSchema: { scene: z.string(), confirmation: confirmationInput } },
|
|
6
|
+
// withConfirmation((args) => renderFilm(ctx, identity.userId, args), {
|
|
7
|
+
// ctx, identity, tool: "render_film",
|
|
8
|
+
// estimate: (args) => estimateFilm(ctx, identity.userId, args), // { total: 12, currency: "credits", ... }
|
|
9
|
+
// }));
|
|
10
|
+
//
|
|
11
|
+
// The token is a stateless HMAC (`MCP_CONFIRMATION_SECRET`) over the tool, a digest of the canonical JSON of the
|
|
12
|
+
// arguments, a digest of the account and access token, the estimate's total and an expiry. Its payload carries
|
|
13
|
+
// those digests in the clear, so each mismatch gets its own code after the signature has been checked. It is
|
|
14
|
+
// single use: the confirmed call stores its hash (`mcp.used_confirmations`) before the work runs.
|
|
15
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import { formatMessage } from "@softure-ai/core";
|
|
17
|
+
import { lte } from "drizzle-orm";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import type { McpServerIdentity } from "../contract.js";
|
|
20
|
+
import { en } from "../messages/en.js";
|
|
21
|
+
import { usedConfirmations } from "../schema.js";
|
|
22
|
+
import { getMcpAccessMessages } from "./options.js";
|
|
23
|
+
import type { McpAccessContext } from "./tokens.js";
|
|
24
|
+
import { withToolErrors, type ToolCaller, type ToolCallHook, type ToolErrorHints } from "./tools.js";
|
|
25
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
26
|
+
|
|
27
|
+
/** The argument that carries the token on the confirmed call. */
|
|
28
|
+
export const CONFIRMATION_FIELD = "confirmation";
|
|
29
|
+
|
|
30
|
+
/** Signs and verifies confirmation tokens. */
|
|
31
|
+
export const CONFIRMATION_SECRET_ENV = "MCP_CONFIRMATION_SECRET";
|
|
32
|
+
|
|
33
|
+
/** A shorter secret counts as missing: a guessable key lets anyone mint a confirmation. */
|
|
34
|
+
export const MIN_CONFIRMATION_SECRET_LENGTH = 32;
|
|
35
|
+
|
|
36
|
+
/** How long a confirmation stays valid by default: fifteen minutes. */
|
|
37
|
+
export const DEFAULT_CONFIRMATION_TTL_SECONDS = 900;
|
|
38
|
+
|
|
39
|
+
/** The longest token the check looks at; a real one is about 300 characters. */
|
|
40
|
+
const MAX_CONFIRMATION_LENGTH = 2048;
|
|
41
|
+
|
|
42
|
+
/** The `confirmation` field for the tool's input schema, so a strict server lists and accepts it. */
|
|
43
|
+
export const confirmationInput = z.string().max(MAX_CONFIRMATION_LENGTH).optional().describe(en.confirmation.fieldDescription);
|
|
44
|
+
|
|
45
|
+
/** The stable codes of a refused confirmed call. */
|
|
46
|
+
export type ConfirmationErrorCode =
|
|
47
|
+
| "input.confirmation_invalid"
|
|
48
|
+
| "input.confirmation_other_tool"
|
|
49
|
+
| "input.confirmation_other_account"
|
|
50
|
+
| "input.confirmation_expired"
|
|
51
|
+
| "input.confirmation_arguments_changed"
|
|
52
|
+
| "input.confirmation_estimate_changed"
|
|
53
|
+
| "input.confirmation_used";
|
|
54
|
+
|
|
55
|
+
/** What a call would do and cost: `total` is bound to the token; every other field is shown to the person as it is. */
|
|
56
|
+
export interface ConfirmationEstimate {
|
|
57
|
+
readonly total: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The answer of a call that needs the person's yes first. */
|
|
61
|
+
export interface ConfirmationRequest<Estimate extends ConfirmationEstimate = ConfirmationEstimate> {
|
|
62
|
+
readonly needsConfirmation: true;
|
|
63
|
+
readonly estimate: Estimate;
|
|
64
|
+
readonly confirmation: string;
|
|
65
|
+
/** ISO 8601. */
|
|
66
|
+
readonly expiresAt: string;
|
|
67
|
+
/** The fixed sentence that tells the assistant to ask the person before calling again. */
|
|
68
|
+
readonly message: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The tool's arguments as the SDK passes them, `confirmation` included. */
|
|
72
|
+
export type ConfirmedToolArgs<Args> = Args & { readonly confirmation?: string | undefined };
|
|
73
|
+
|
|
74
|
+
export interface WithConfirmationOptions<Args, Estimate extends ConfirmationEstimate> {
|
|
75
|
+
/** The module context: the database for the used tokens, the clock for the expiry, the config for the copy. */
|
|
76
|
+
readonly ctx: McpAccessContext;
|
|
77
|
+
/** The request's identity, as the server factory got it: the token is bound to its account and access token. */
|
|
78
|
+
readonly identity: Pick<McpServerIdentity, "userId" | "tokenId">;
|
|
79
|
+
/** The tool's registered name, also its label in the log line and the `onToolCall` record. */
|
|
80
|
+
readonly tool: string;
|
|
81
|
+
/** What the call would do and cost, without doing it. Runs on both calls; the second must give the same total. */
|
|
82
|
+
readonly estimate: (args: Args) => Estimate | Promise<Estimate>;
|
|
83
|
+
/** How long the person has to agree. Default: 900 seconds. */
|
|
84
|
+
readonly ttlSeconds?: number;
|
|
85
|
+
/** Runs the handler at once, without asking, when this answers true, e.g. `(estimate) => estimate.total === 0`. */
|
|
86
|
+
readonly runWithoutConfirmationWhen?: (estimate: Estimate) => boolean;
|
|
87
|
+
/** Default: `process.env.MCP_CONFIRMATION_SECRET`, read on every call. */
|
|
88
|
+
readonly secret?: string;
|
|
89
|
+
/** Texts per error code, as in `withToolErrors`; they also replace the refusal texts of the dictionary. */
|
|
90
|
+
readonly hints?: ToolErrorHints;
|
|
91
|
+
/** Called once after each call with its record (`confirmed` included), as in `withToolErrors`. */
|
|
92
|
+
readonly onToolCall?: ToolCallHook;
|
|
93
|
+
/** Who called, for the record, as in `withToolErrors`. */
|
|
94
|
+
readonly caller?: ToolCaller;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The token's signed payload. Short keys keep the token short. */
|
|
98
|
+
interface ConfirmationPayload {
|
|
99
|
+
/** Format version. */
|
|
100
|
+
readonly v: 1;
|
|
101
|
+
/** Tool name. */
|
|
102
|
+
readonly t: string;
|
|
103
|
+
/** sha256 of the canonical JSON of the arguments without `confirmation`. */
|
|
104
|
+
readonly a: string;
|
|
105
|
+
/** sha256 of the user id and the access token id. */
|
|
106
|
+
readonly c: string;
|
|
107
|
+
/** The estimate's total. */
|
|
108
|
+
readonly n: number;
|
|
109
|
+
/** Expiry, epoch seconds. */
|
|
110
|
+
readonly e: number;
|
|
111
|
+
/** A random nonce, so two estimates of the same input are two tokens. */
|
|
112
|
+
readonly r: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Domain separation: the same key signing anything else can never produce a valid confirmation. Changing it voids
|
|
117
|
+
* every token in flight, exactly like changing the key.
|
|
118
|
+
*/
|
|
119
|
+
const SIGNED_PREFIX = "softure.mcp-access.confirmation.v1:";
|
|
120
|
+
|
|
121
|
+
const TOKEN_PATTERN = /^([A-Za-z0-9_-]{1,2000})\.([A-Za-z0-9_-]{43})$/;
|
|
122
|
+
const DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
123
|
+
|
|
124
|
+
const payloadSchema = z.strictObject({
|
|
125
|
+
v: z.literal(1),
|
|
126
|
+
t: z.string().min(1).max(128),
|
|
127
|
+
a: z.string().regex(DIGEST_PATTERN),
|
|
128
|
+
c: z.string().regex(DIGEST_PATTERN),
|
|
129
|
+
n: z.number().finite(),
|
|
130
|
+
e: z.number().int().positive(),
|
|
131
|
+
r: z.string().regex(/^[A-Za-z0-9_-]{16,64}$/),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tool callback for `server.registerTool(name, { inputSchema: { ...fields, confirmation: confirmationInput } },
|
|
136
|
+
* withConfirmation(handler, options))`. Without `confirmation` it runs `estimate` only and answers a
|
|
137
|
+
* `ConfirmationRequest` (or runs the handler when `runWithoutConfirmationWhen` says so); with a valid one it runs
|
|
138
|
+
* the handler with the arguments without `confirmation`. A refusal answers its `input.confirmation_*` text.
|
|
139
|
+
*/
|
|
140
|
+
export function withConfirmation<Args extends Readonly<Record<string, unknown>>, Estimate extends ConfirmationEstimate>(
|
|
141
|
+
handler: (args: Args) => unknown,
|
|
142
|
+
options: WithConfirmationOptions<Args, Estimate>,
|
|
143
|
+
): (args: ConfirmedToolArgs<Args>) => Promise<CallToolResult> {
|
|
144
|
+
return async (input) => {
|
|
145
|
+
const { [CONFIRMATION_FIELD]: confirmation, ...rest } = input;
|
|
146
|
+
const args = rest as unknown as Args;
|
|
147
|
+
let confirmed = false;
|
|
148
|
+
const recordOptions = {
|
|
149
|
+
label: options.tool,
|
|
150
|
+
args: rest,
|
|
151
|
+
hints: { ...getRefusalTexts(options.ctx), ...options.hints },
|
|
152
|
+
// Read after the work, when the call has decided whether it ran on a confirmation.
|
|
153
|
+
get confirmed(): boolean {
|
|
154
|
+
return confirmed;
|
|
155
|
+
},
|
|
156
|
+
...(options.onToolCall === undefined ? {} : { onToolCall: options.onToolCall }),
|
|
157
|
+
...(options.caller === undefined ? {} : { caller: options.caller }),
|
|
158
|
+
};
|
|
159
|
+
return withToolErrors(async () => {
|
|
160
|
+
const secret = readConfirmationSecret(options);
|
|
161
|
+
const estimate = await runEstimate(options, args);
|
|
162
|
+
if (confirmation === undefined) {
|
|
163
|
+
if (options.runWithoutConfirmationWhen?.(estimate) === true) return handler(args);
|
|
164
|
+
return requestConfirmation(options, args, estimate, secret);
|
|
165
|
+
}
|
|
166
|
+
const refusal = await checkConfirmation(options, { token: confirmation, args, estimate, secret });
|
|
167
|
+
if (refusal !== null) return { ok: false, error: refusal };
|
|
168
|
+
confirmed = true;
|
|
169
|
+
return handler(args);
|
|
170
|
+
}, recordOptions);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Deletes the used tokens past their expiry; a daily job through `pruneMcpAccess`. */
|
|
175
|
+
export async function pruneUsedConfirmations(ctx: McpAccessContext): Promise<number> {
|
|
176
|
+
const removed = await ctx.db
|
|
177
|
+
.delete(usedConfirmations)
|
|
178
|
+
.where(lte(usedConfirmations.expiresAt, ctx.clock.now()))
|
|
179
|
+
.returning({ tokenHash: usedConfirmations.tokenHash });
|
|
180
|
+
return removed.length;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readConfirmationSecret(options: { readonly secret?: string; readonly tool: string }): string {
|
|
184
|
+
const secret = options.secret ?? process.env[CONFIRMATION_SECRET_ENV];
|
|
185
|
+
if (secret !== undefined && secret.length >= MIN_CONFIRMATION_SECRET_LENGTH) return secret;
|
|
186
|
+
// The boundary logs a thrown error by its kind only, so the cause is named here once.
|
|
187
|
+
console.error(`@softure-ai/mcp-access: tool ${options.tool} needs ${CONFIRMATION_SECRET_ENV} (at least ${String(MIN_CONFIRMATION_SECRET_LENGTH)} characters)`);
|
|
188
|
+
throw new Error(`@softure-ai/mcp-access: ${CONFIRMATION_SECRET_ENV} is missing or shorter than ${String(MIN_CONFIRMATION_SECRET_LENGTH)} characters`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function runEstimate<Args, Estimate extends ConfirmationEstimate>(options: WithConfirmationOptions<Args, Estimate>, args: Args): Promise<Estimate> {
|
|
192
|
+
const estimate = await options.estimate(args);
|
|
193
|
+
if (typeof estimate !== "object" || estimate === null || typeof estimate.total !== "number" || !Number.isFinite(estimate.total)) {
|
|
194
|
+
throw new Error(`@softure-ai/mcp-access: the estimate of tool ${options.tool} has no finite numeric total`);
|
|
195
|
+
}
|
|
196
|
+
return estimate;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function requestConfirmation<Args, Estimate extends ConfirmationEstimate>(
|
|
200
|
+
options: WithConfirmationOptions<Args, Estimate>,
|
|
201
|
+
args: Args,
|
|
202
|
+
estimate: Estimate,
|
|
203
|
+
secret: string,
|
|
204
|
+
): ConfirmationRequest<Estimate> {
|
|
205
|
+
const ttlSeconds = options.ttlSeconds ?? DEFAULT_CONFIRMATION_TTL_SECONDS;
|
|
206
|
+
const expiresAtSeconds = Math.floor(options.ctx.clock.now().getTime() / 1000) + Math.max(1, Math.floor(ttlSeconds));
|
|
207
|
+
const payload: ConfirmationPayload = {
|
|
208
|
+
v: 1,
|
|
209
|
+
t: options.tool,
|
|
210
|
+
a: digestArguments(args),
|
|
211
|
+
c: digestCaller(options.identity),
|
|
212
|
+
n: estimate.total,
|
|
213
|
+
e: expiresAtSeconds,
|
|
214
|
+
r: randomBytes(16).toString("base64url"),
|
|
215
|
+
};
|
|
216
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
217
|
+
const expiresAt = new Date(expiresAtSeconds * 1000).toISOString();
|
|
218
|
+
return {
|
|
219
|
+
needsConfirmation: true,
|
|
220
|
+
estimate,
|
|
221
|
+
confirmation: `${encoded}.${sign(encoded, secret)}`,
|
|
222
|
+
expiresAt,
|
|
223
|
+
message: formatMessage(getMcpAccessMessages(options.ctx.config).confirmation.ask, { expiresAt }),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface ConfirmationCheck<Args, Estimate> {
|
|
228
|
+
readonly token: string;
|
|
229
|
+
readonly args: Args;
|
|
230
|
+
readonly estimate: Estimate;
|
|
231
|
+
readonly secret: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The refusal code, or null when the call may run. The signature is checked first, so no field of the payload is
|
|
236
|
+
* trusted before it; the token is consumed last, so a refused call never uses up a valid confirmation.
|
|
237
|
+
*/
|
|
238
|
+
async function checkConfirmation<Args, Estimate extends ConfirmationEstimate>(
|
|
239
|
+
options: WithConfirmationOptions<Args, Estimate>,
|
|
240
|
+
check: ConfirmationCheck<Args, Estimate>,
|
|
241
|
+
): Promise<ConfirmationErrorCode | null> {
|
|
242
|
+
const payload = readSignedPayload(check.token, check.secret);
|
|
243
|
+
if (payload === null) return "input.confirmation_invalid";
|
|
244
|
+
if (payload.t !== options.tool) return "input.confirmation_other_tool";
|
|
245
|
+
if (payload.c !== digestCaller(options.identity)) return "input.confirmation_other_account";
|
|
246
|
+
const now = options.ctx.clock.now();
|
|
247
|
+
if (now.getTime() >= payload.e * 1000) return "input.confirmation_expired";
|
|
248
|
+
if (payload.a !== digestArguments(check.args)) return "input.confirmation_arguments_changed";
|
|
249
|
+
if (payload.n !== check.estimate.total) return "input.confirmation_estimate_changed";
|
|
250
|
+
const inserted = await options.ctx.db
|
|
251
|
+
.insert(usedConfirmations)
|
|
252
|
+
.values({ tokenHash: createHash("sha256").update(check.token).digest("hex"), usedAt: now, expiresAt: new Date(payload.e * 1000) })
|
|
253
|
+
.onConflictDoNothing()
|
|
254
|
+
.returning({ tokenHash: usedConfirmations.tokenHash });
|
|
255
|
+
return inserted.length === 0 ? "input.confirmation_used" : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The payload of a token this secret signed, else null. Never throws. */
|
|
259
|
+
function readSignedPayload(token: string, secret: string): ConfirmationPayload | null {
|
|
260
|
+
if (token.length > MAX_CONFIRMATION_LENGTH) return null;
|
|
261
|
+
const match = TOKEN_PATTERN.exec(token);
|
|
262
|
+
if (match === null) return null;
|
|
263
|
+
const [, encoded = "", signature = ""] = match;
|
|
264
|
+
// Both are 43 characters of base64url (the pattern), so the buffers have the same length.
|
|
265
|
+
if (!timingSafeEqual(Buffer.from(sign(encoded, secret)), Buffer.from(signature))) return null;
|
|
266
|
+
let decoded: unknown;
|
|
267
|
+
try {
|
|
268
|
+
decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
269
|
+
} catch {
|
|
270
|
+
// Signed by this secret but unreadable: only a key shared with another signer could produce that.
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const parsed = payloadSchema.safeParse(decoded);
|
|
274
|
+
return parsed.success ? parsed.data : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sign(encoded: string, secret: string): string {
|
|
278
|
+
return createHmac("sha256", secret).update(`${SIGNED_PREFIX}${encoded}`).digest("base64url");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function digestArguments(args: unknown): string {
|
|
282
|
+
return createHash("sha256").update(toCanonicalJson(args)).digest("base64url");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function digestCaller(identity: Pick<McpServerIdentity, "userId" | "tokenId">): string {
|
|
286
|
+
return createHash("sha256").update(JSON.stringify([identity.userId, identity.tokenId])).digest("base64url");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** JSON with every object's keys sorted and `undefined` fields left out, so equal arguments give equal text. */
|
|
290
|
+
export function toCanonicalJson(value: unknown): string {
|
|
291
|
+
return JSON.stringify(sortKeys(value)) ?? "null";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sortKeys(value: unknown): unknown {
|
|
295
|
+
if (Array.isArray(value)) return value.map((item) => (item === undefined ? null : sortKeys(item)));
|
|
296
|
+
if (value instanceof Date) return value.toISOString();
|
|
297
|
+
if (typeof value !== "object" || value === null) return value;
|
|
298
|
+
const entries = Object.entries(value)
|
|
299
|
+
.filter(([, item]) => item !== undefined)
|
|
300
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
301
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, sortKeys(item)]));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The dictionary's refusal texts per code, with the app's overrides. */
|
|
305
|
+
function getRefusalTexts(ctx: McpAccessContext): Record<ConfirmationErrorCode, string> {
|
|
306
|
+
const texts = getMcpAccessMessages(ctx.config).confirmation;
|
|
307
|
+
return {
|
|
308
|
+
"input.confirmation_invalid": texts.invalid,
|
|
309
|
+
"input.confirmation_other_tool": texts.otherTool,
|
|
310
|
+
"input.confirmation_other_account": texts.otherAccount,
|
|
311
|
+
"input.confirmation_expired": texts.expired,
|
|
312
|
+
"input.confirmation_arguments_changed": texts.argumentsChanged,
|
|
313
|
+
"input.confirmation_estimate_changed": texts.estimateChanged,
|
|
314
|
+
"input.confirmation_used": texts.used,
|
|
315
|
+
};
|
|
316
|
+
}
|
package/src/server/health.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table (with
|
|
2
|
-
// its grant column), the grants table
|
|
2
|
+
// its grant column), the grants table, the registration window, the used confirmations and the operation tasks exist and answer, i.e.
|
|
3
3
|
// `softure migrate` ran every file. It reads no rows.
|
|
4
4
|
import { ok, type HealthCheck } from "@softure-ai/core";
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
@@ -11,5 +11,7 @@ export const checkAccessTokensTable: HealthCheck = async (context) => {
|
|
|
11
11
|
await db.execute(sql`select grant_id from mcp.access_tokens limit 0`);
|
|
12
12
|
await db.execute(sql`select 1 from mcp.oauth_grants join mcp.oauth_clients on oauth_clients.id = oauth_grants.client_id limit 0`);
|
|
13
13
|
await db.execute(sql`select open_until from mcp.oauth_registration_window limit 0`);
|
|
14
|
+
await db.execute(sql`select expires_at from mcp.used_confirmations limit 0`);
|
|
15
|
+
await db.execute(sql`select expires_at from mcp.operation_tasks limit 0`);
|
|
14
16
|
return ok();
|
|
15
17
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -11,6 +11,21 @@ export {
|
|
|
11
11
|
type McpServerFactory,
|
|
12
12
|
} from "./endpoint.js";
|
|
13
13
|
export { actionTool, toFormData, type ActionToolOptions, type FormActionHandler } from "./action-tool.js";
|
|
14
|
+
export {
|
|
15
|
+
CONFIRMATION_FIELD,
|
|
16
|
+
CONFIRMATION_SECRET_ENV,
|
|
17
|
+
confirmationInput,
|
|
18
|
+
DEFAULT_CONFIRMATION_TTL_SECONDS,
|
|
19
|
+
MIN_CONFIRMATION_SECRET_LENGTH,
|
|
20
|
+
pruneUsedConfirmations,
|
|
21
|
+
toCanonicalJson,
|
|
22
|
+
withConfirmation,
|
|
23
|
+
type ConfirmationErrorCode,
|
|
24
|
+
type ConfirmationEstimate,
|
|
25
|
+
type ConfirmationRequest,
|
|
26
|
+
type ConfirmedToolArgs,
|
|
27
|
+
type WithConfirmationOptions,
|
|
28
|
+
} from "./confirmation.js";
|
|
14
29
|
export { createMcpAccessContext } from "./context.js";
|
|
15
30
|
export { isWritingEnabled } from "./writes-switch.js";
|
|
16
31
|
export { checkAccessTokensTable } from "./health.js";
|
|
@@ -79,7 +94,7 @@ export type { OAuthClientRow, OAuthTokenEndpointAuthMethod } from "../schema.js"
|
|
|
79
94
|
export { describeRedirectUri, isAllowedRedirectUri, MAX_CLIENT_NAME_LENGTH, parseClientRegistration, type ClientRegistrationResult, type RegistrationError } from "./oauth-validation.js";
|
|
80
95
|
export { getCodeChallenge, isPkceVerifierValid, isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
|
|
81
96
|
export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
|
|
82
|
-
export { pruneMcpAccess, type PrunedMcpAccessRecords } from "./prune.js";
|
|
97
|
+
export { pruneMcpAccess, pruneOperationTasks, type PrunedMcpAccessRecords } from "./prune.js";
|
|
83
98
|
export {
|
|
84
99
|
deleteMcpAccessUserData,
|
|
85
100
|
exportMcpAccessUserData,
|
|
@@ -103,6 +118,21 @@ export {
|
|
|
103
118
|
type RevokeAccessTokenInput,
|
|
104
119
|
type VerifiedAccessToken,
|
|
105
120
|
} from "./tokens.js";
|
|
121
|
+
export {
|
|
122
|
+
API_RATE_LIMIT_BUCKET,
|
|
123
|
+
API_TOKEN_RATE_LIMIT_BUCKET,
|
|
124
|
+
apiTokenErrorResponse,
|
|
125
|
+
getApiTokenOptions,
|
|
126
|
+
isApiTokenShape,
|
|
127
|
+
issueApiToken,
|
|
128
|
+
requireApiToken,
|
|
129
|
+
verifyApiToken,
|
|
130
|
+
type IssueApiTokenInput,
|
|
131
|
+
type IssueApiTokenResult,
|
|
132
|
+
type IssuedApiToken,
|
|
133
|
+
type RequireApiTokenOptions,
|
|
134
|
+
type VerifiedApiToken,
|
|
135
|
+
} from "./api-tokens.js";
|
|
106
136
|
export { createMcpServerFactory, type McpServerFactoryOptions } from "./server-factory.js";
|
|
107
137
|
export {
|
|
108
138
|
registerServerInfoTool,
|
|
@@ -113,7 +143,7 @@ export {
|
|
|
113
143
|
type ServerInfoAnswer,
|
|
114
144
|
type ServerInfoToolOptions,
|
|
115
145
|
} from "./server-info.js";
|
|
116
|
-
export { toStrictToolInput, withStrictToolInput, type StrictToolInputMessages, type StrictToolInputOptions } from "./strict-input.js";
|
|
146
|
+
export { toStrictToolInput, withStrictToolInput, type RenamedFields, type StrictToolInputMessages, type StrictToolInputOptions } from "./strict-input.js";
|
|
117
147
|
export {
|
|
118
148
|
DEFAULT_TOOL_LIST_RELEASE,
|
|
119
149
|
describeToolList,
|
package/src/server/privacy.ts
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
6
6
|
import { and, asc, eq, isNull } from "drizzle-orm";
|
|
7
|
-
import { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants } from "../schema.js";
|
|
7
|
+
import { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants, operationTasks } from "../schema.js";
|
|
8
8
|
|
|
9
9
|
/** What mcp-access holds about one user, as it appears in their export. */
|
|
10
10
|
export interface McpAccessUserData {
|
|
11
11
|
readonly accessTokens: readonly {
|
|
12
12
|
readonly name: string;
|
|
13
|
+
/** `mcp` for an assistant, `api` for the app's HTTP routes. */
|
|
14
|
+
readonly kind: "mcp" | "api";
|
|
15
|
+
/** The app scopes of an API token; empty for an MCP token. */
|
|
16
|
+
readonly scopes: readonly string[];
|
|
13
17
|
readonly canWrite: boolean;
|
|
14
18
|
readonly createdAt: Date;
|
|
15
19
|
readonly expiresAt: Date;
|
|
@@ -33,6 +37,8 @@ export async function exportMcpAccessUserData(context: ModuleContext, userId: st
|
|
|
33
37
|
const tokens = await db
|
|
34
38
|
.select({
|
|
35
39
|
name: accessTokens.name,
|
|
40
|
+
kind: accessTokens.kind,
|
|
41
|
+
scopes: accessTokens.scopes,
|
|
36
42
|
canWrite: accessTokens.canWrite,
|
|
37
43
|
createdAt: accessTokens.createdAt,
|
|
38
44
|
expiresAt: accessTokens.expiresAt,
|
|
@@ -57,6 +63,8 @@ export async function deleteMcpAccessUserData(context: ModuleContext, userId: st
|
|
|
57
63
|
// A grant's access tokens go with it by cascade; the next line takes the hand-issued ones.
|
|
58
64
|
await db.delete(oauthGrants).where(eq(oauthGrants.userId, userId));
|
|
59
65
|
await db.delete(accessTokens).where(eq(accessTokens.userId, userId));
|
|
66
|
+
// MCP tasks hold a job id and dates only, so the export leaves them out; they go with the account.
|
|
67
|
+
await db.delete(operationTasks).where(eq(operationTasks.userId, userId));
|
|
60
68
|
return ok();
|
|
61
69
|
}
|
|
62
70
|
|