@softure-ai/mcp-access 0.1.12 → 0.1.13
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 +23 -0
- package/README.md +126 -4
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +30 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +24 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +58 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +4 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +20 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -11
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +4 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +2 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +2 -2
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +8 -47
- package/dist/next/actions.js.map +1 -1
- package/dist/next/format.d.ts +1 -1
- package/dist/next/format.d.ts.map +1 -1
- package/dist/next/format.js +3 -1
- package/dist/next/format.js.map +1 -1
- package/dist/next/index.d.ts +2 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/issue-token.d.ts +15 -0
- package/dist/next/issue-token.d.ts.map +1 -0
- package/dist/next/issue-token.js +77 -0
- package/dist/next/issue-token.js.map +1 -0
- package/dist/next/pages.d.ts +9 -1
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +2 -2
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +16 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -1
- package/dist/routes.d.ts +15 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +17 -0
- package/dist/routes.js.map +1 -0
- package/dist/server/action-tool.d.ts +21 -0
- package/dist/server/action-tool.d.ts.map +1 -0
- package/dist/server/action-tool.js +51 -0
- package/dist/server/action-tool.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/prune.d.ts +8 -0
- package/dist/server/prune.d.ts.map +1 -0
- package/dist/server/prune.js +10 -0
- package/dist/server/prune.js.map +1 -0
- package/dist/server/tools.d.ts +25 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +61 -0
- package/dist/server/tools.js.map +1 -0
- package/dist/stdio/index.d.ts +2 -0
- package/dist/stdio/index.d.ts.map +1 -0
- package/dist/stdio/index.js +4 -0
- package/dist/stdio/index.js.map +1 -0
- package/dist/stdio/register.d.ts +2 -0
- package/dist/stdio/register.d.ts.map +1 -0
- package/dist/stdio/register.js +10 -0
- package/dist/stdio/register.js.map +1 -0
- package/dist/stdio/serve.d.ts +45 -0
- package/dist/stdio/serve.d.ts.map +1 -0
- package/dist/stdio/serve.js +89 -0
- package/dist/stdio/serve.js.map +1 -0
- package/dist/testing/catalog.d.ts +15 -0
- package/dist/testing/catalog.d.ts.map +1 -0
- package/dist/testing/catalog.js +51 -0
- package/dist/testing/catalog.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/module.json +1 -1
- package/package.json +31 -5
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +38 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +79 -0
- package/src/contract.ts +5 -2
- package/src/index.ts +9 -12
- package/src/messages/en.ts +2 -0
- package/src/messages/pl.ts +2 -0
- package/src/next/actions.ts +8 -45
- package/src/next/format.ts +2 -2
- package/src/next/index.ts +2 -1
- package/src/next/issue-token.ts +90 -0
- package/src/next/pages.tsx +11 -3
- package/src/options.ts +4 -0
- package/src/routes.ts +17 -0
- package/src/server/action-tool.ts +64 -0
- package/src/server/index.ts +3 -0
- package/src/server/prune.ts +14 -0
- package/src/server/tools.ts +72 -0
- package/src/stdio/index.ts +12 -0
- package/src/stdio/register.ts +11 -0
- package/src/stdio/serve.ts +133 -0
- package/src/testing/catalog.ts +74 -0
- package/src/testing/index.ts +2 -0
package/src/cli/bin.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `softure-mcp` executable. All logic lives in command.ts and run.ts, which the tests call directly.
|
|
3
|
+
import { runMcpAccessCommand } from "./command.js";
|
|
4
|
+
|
|
5
|
+
process.exitCode = await runMcpAccessCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The `softure-mcp` bin. It loads the app's config through `@softure-ai/core/cli`, like `softure-mail`
|
|
2
|
+
// (@softure-ai/mailing): the file must be loadable by Node itself (`.js`/`.mjs`, or `.ts` where Node
|
|
3
|
+
// strips types). Anything else uses an app script with `runMcpAccessCli` instead (see run.ts).
|
|
4
|
+
import { loadAppConfig, takeConfigOption } from "@softure-ai/core/cli";
|
|
5
|
+
import { EXIT_FAILED, EXIT_USAGE, MCP_ACCESS_USAGE, runMcpAccessCli, type CliOutput } from "./run.js";
|
|
6
|
+
|
|
7
|
+
export interface RunMcpAccessCommandOptions {
|
|
8
|
+
readonly argv: readonly string[];
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly output?: CliOutput;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const APP_SCRIPT = { runner: "runMcpAccessCli", packageName: "@softure-ai/mcp-access" };
|
|
14
|
+
|
|
15
|
+
const consoleOutput: CliOutput = {
|
|
16
|
+
log: (line) => console.log(line),
|
|
17
|
+
error: (line) => console.error(line),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Loads the config (unless only help is asked for) and runs `runMcpAccessCli`. */
|
|
21
|
+
export async function runMcpAccessCommand(options: RunMcpAccessCommandOptions): Promise<number> {
|
|
22
|
+
const output = options.output ?? consoleOutput;
|
|
23
|
+
const taken = takeConfigOption(options.argv);
|
|
24
|
+
if (!taken.ok) {
|
|
25
|
+
output.error(`softure-mcp: ${taken.problem}`);
|
|
26
|
+
output.error(MCP_ACCESS_USAGE);
|
|
27
|
+
return EXIT_USAGE;
|
|
28
|
+
}
|
|
29
|
+
const { configPath, argv } = taken;
|
|
30
|
+
if (argv[0] !== "prune") return runMcpAccessCli({ argv, output });
|
|
31
|
+
|
|
32
|
+
const loaded = await loadAppConfig({ cwd: options.cwd, configPath, appScript: APP_SCRIPT });
|
|
33
|
+
if (!loaded.ok) {
|
|
34
|
+
output.error(`softure-mcp: ${loaded.problem}`);
|
|
35
|
+
return EXIT_FAILED;
|
|
36
|
+
}
|
|
37
|
+
return runMcpAccessCli({ config: loaded.config, argv, output });
|
|
38
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// `@softure-ai/mcp-access/cli`: the `softure-mcp` command for app scripts and container bundles.
|
|
2
|
+
export { runMcpAccessCommand, type RunMcpAccessCommandOptions } from "./command.js";
|
|
3
|
+
export { EXIT_FAILED, EXIT_OK, EXIT_USAGE, MCP_ACCESS_USAGE, runMcpAccessCli, type CliOutput, type RunMcpAccessCliOptions } from "./run.js";
|
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// `softure-mcp`: the module's maintenance command. `prune` deletes expired access tokens and dead OAuth
|
|
2
|
+
// records (`pruneMcpAccess`), for a daily job such as a deploy `maintain` hook. The app passes its
|
|
3
|
+
// validated config, so the same function serves the bin and an app script a bundler packs:
|
|
4
|
+
//
|
|
5
|
+
// // scripts/mcp.ts
|
|
6
|
+
// import { runMcpAccessCli } from "@softure-ai/mcp-access/cli";
|
|
7
|
+
// import config from "../softure.config";
|
|
8
|
+
// process.exitCode = await runMcpAccessCli({ config, argv: process.argv.slice(2) });
|
|
9
|
+
import { errorLogLabel, systemClock, type Clock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
10
|
+
import { openCommandDatabase, type CommandDatabase } from "@softure-ai/db";
|
|
11
|
+
import { pruneMcpAccess } from "../server/prune.js";
|
|
12
|
+
|
|
13
|
+
export interface CliOutput {
|
|
14
|
+
readonly log: (line: string) => void;
|
|
15
|
+
readonly error: (line: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RunMcpAccessCliOptions {
|
|
19
|
+
/** The app's config; only help needs none. */
|
|
20
|
+
readonly config?: SoftureConfig;
|
|
21
|
+
/** The arguments after the executable, e.g. `["prune"]`. */
|
|
22
|
+
readonly argv: readonly string[];
|
|
23
|
+
readonly output?: CliOutput;
|
|
24
|
+
/** Default: the wall clock. */
|
|
25
|
+
readonly clock?: Clock;
|
|
26
|
+
/** Default: the config's `database.handle`, else a connection on `database.url`. */
|
|
27
|
+
readonly openDatabase?: (database: SoftureDatabaseConfig) => Promise<CommandDatabase>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EXIT_OK = 0;
|
|
31
|
+
export const EXIT_FAILED = 1;
|
|
32
|
+
export const EXIT_USAGE = 2;
|
|
33
|
+
|
|
34
|
+
export const MCP_ACCESS_USAGE = `Usage:
|
|
35
|
+
softure-mcp prune deletes expired access tokens, authorization codes, grants and abandoned clients`;
|
|
36
|
+
|
|
37
|
+
const consoleOutput: CliOutput = {
|
|
38
|
+
log: (line) => console.log(line),
|
|
39
|
+
error: (line) => console.error(line),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Runs one command and answers its exit code. */
|
|
43
|
+
export async function runMcpAccessCli(options: RunMcpAccessCliOptions): Promise<number> {
|
|
44
|
+
const output = options.output ?? consoleOutput;
|
|
45
|
+
const [command, ...rest] = options.argv;
|
|
46
|
+
if (command === undefined || command === "--help" || command === "help") {
|
|
47
|
+
output.log(MCP_ACCESS_USAGE);
|
|
48
|
+
return EXIT_OK;
|
|
49
|
+
}
|
|
50
|
+
if (command !== "prune" || rest.length > 0) {
|
|
51
|
+
output.error(command === "prune" ? `softure-mcp prune: unexpected argument "${rest[0] ?? ""}"` : `softure-mcp: unknown command "${command}"`);
|
|
52
|
+
output.error(MCP_ACCESS_USAGE);
|
|
53
|
+
return EXIT_USAGE;
|
|
54
|
+
}
|
|
55
|
+
return prune(options, output);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function prune(options: RunMcpAccessCliOptions, output: CliOutput): Promise<number> {
|
|
59
|
+
const database = options.config?.database ?? null;
|
|
60
|
+
if (options.config === undefined || database === null) {
|
|
61
|
+
output.error("softure-mcp prune: the config has no database; set database.url in softure.config");
|
|
62
|
+
return EXIT_FAILED;
|
|
63
|
+
}
|
|
64
|
+
let opened: CommandDatabase | undefined;
|
|
65
|
+
try {
|
|
66
|
+
opened = await (options.openDatabase ?? ((settings) => openCommandDatabase(settings, { max: 1 })))(database);
|
|
67
|
+
const removed = await pruneMcpAccess({ db: opened.handle.db, clock: options.clock ?? systemClock, config: options.config });
|
|
68
|
+
output.log(
|
|
69
|
+
`softure-mcp prune: removed ${String(removed.accessTokens)} access tokens, ${String(removed.codes)} authorization codes, ${String(removed.grants)} grants, ${String(removed.clients)} clients`,
|
|
70
|
+
);
|
|
71
|
+
return EXIT_OK;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// The kind only: a driver message can carry the database address or credentials.
|
|
74
|
+
output.error(`softure-mcp prune: failed: ${errorLogLabel(error)}`);
|
|
75
|
+
return EXIT_FAILED;
|
|
76
|
+
} finally {
|
|
77
|
+
await opened?.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/contract.ts
CHANGED
|
@@ -9,8 +9,11 @@ export type McpAccessErrorCode =
|
|
|
9
9
|
| "mcp-access.token_not_found"
|
|
10
10
|
| "mcp-access.grant_not_found";
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
export type
|
|
12
|
+
/** What an app's `beforeIssue` gate may answer instead of letting a token be issued. */
|
|
13
|
+
export type McpIssueRefusalCode = "mcp-access.issue_refused" | "mcp-access.write_refused";
|
|
14
|
+
|
|
15
|
+
/** Every code the token page can show: its own, a gate's refusal, a missing session and the generic ones. */
|
|
16
|
+
export type TokenFormErrorCode = McpAccessErrorCode | McpIssueRefusalCode | "auth.unauthenticated" | CoreErrorCode;
|
|
14
17
|
|
|
15
18
|
/** What the app's MCP server factory receives for one request. */
|
|
16
19
|
export interface McpServerIdentity {
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// work and the endpoint are in `@softure-ai/mcp-access/server`, the Next.js adapter (route, page,
|
|
4
4
|
// actions) in `/next`, the token manager in `/ui`.
|
|
5
5
|
import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
|
|
6
|
+
import type { RateLimitBucketInput } from "@softure-ai/security";
|
|
6
7
|
import { mcpAccessMessages } from "./messages/index.js";
|
|
7
8
|
import { mcpAccessOptionsSchema } from "./options.js";
|
|
9
|
+
import { DEFAULT_MCP_ACCESS_ROUTES } from "./routes.js";
|
|
8
10
|
import { checkAccessTokensTable } from "./server/health.js";
|
|
9
11
|
import { mcpAccessPrivacyContributor } from "./server/privacy.js";
|
|
10
12
|
|
|
@@ -16,14 +18,14 @@ export const MODULE_ID = "mcp-access";
|
|
|
16
18
|
* the traffic behind one address (several assistants share it), not for one token.
|
|
17
19
|
*/
|
|
18
20
|
export const MCP_RATE_LIMIT_BUCKETS = {
|
|
19
|
-
mcp: { limit: 200, windowMinutes: 15 },
|
|
21
|
+
mcp: { limit: 200, windowMinutes: 15, key: "ip" },
|
|
20
22
|
/**
|
|
21
23
|
* OAuth registration (per address) and token requests (per address and client id), when
|
|
22
24
|
* `oauth.enabled`. Enough for an assistant platform refreshing many users' tokens per client,
|
|
23
25
|
* too little for a script filling the clients table.
|
|
24
26
|
*/
|
|
25
|
-
"mcp-oauth": { limit: 60, windowMinutes: 15 },
|
|
26
|
-
} as const
|
|
27
|
+
"mcp-oauth": { limit: 60, windowMinutes: 15, key: "ip" },
|
|
28
|
+
} as const satisfies Readonly<Record<string, RateLimitBucketInput>>;
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Enables MCP access in `softure.config.ts` (after `security` and `auth`):
|
|
@@ -32,20 +34,13 @@ export const MCP_RATE_LIMIT_BUCKETS = {
|
|
|
32
34
|
export const mcpAccess = defineModule({
|
|
33
35
|
manifest: {
|
|
34
36
|
id: MODULE_ID,
|
|
35
|
-
version: "0.1.
|
|
37
|
+
version: "0.1.13",
|
|
36
38
|
dependsOn: { security: "^0.1.0", auth: "^0.1.0" },
|
|
37
39
|
dbSchema: "mcp",
|
|
38
40
|
tables: ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants"],
|
|
39
41
|
env: [],
|
|
40
42
|
switches: [],
|
|
41
|
-
routes: {
|
|
42
|
-
page: "/account/mcp",
|
|
43
|
-
endpoint: "/api/mcp",
|
|
44
|
-
oauthConsent: "/oauth/authorize",
|
|
45
|
-
oauthDecision: "/api/oauth/authorize",
|
|
46
|
-
oauthToken: "/api/oauth/token",
|
|
47
|
-
oauthRegister: "/api/oauth/register",
|
|
48
|
-
},
|
|
43
|
+
routes: { ...DEFAULT_MCP_ACCESS_ROUTES },
|
|
49
44
|
mount: [
|
|
50
45
|
{ kind: "page", path: "app/account/mcp/page.tsx", export: "McpAccessPage" },
|
|
51
46
|
{ kind: "route-handler", path: "app/api/mcp/route.ts", export: "createMcpRoute" },
|
|
@@ -81,6 +76,7 @@ export type {
|
|
|
81
76
|
IssuedToken,
|
|
82
77
|
IssueTokenFormState,
|
|
83
78
|
McpAccessErrorCode,
|
|
79
|
+
McpIssueRefusalCode,
|
|
84
80
|
McpClientSetup,
|
|
85
81
|
McpServerIdentity,
|
|
86
82
|
OAuthGrantView,
|
|
@@ -105,6 +101,7 @@ export {
|
|
|
105
101
|
type McpToolDefinition,
|
|
106
102
|
type McpToolDefinitionInput,
|
|
107
103
|
} from "./options.js";
|
|
104
|
+
export { DEFAULT_MCP_ACCESS_ROUTES } from "./routes.js";
|
|
108
105
|
export { readRequestOrigin, type McpOriginRequest, type McpOrigins } from "./origins.js";
|
|
109
106
|
export { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants, type OAuthClientRow, type OAuthTokenEndpointAuthMethod } from "./schema.js";
|
|
110
107
|
export { getAccessTokenStatus, type AccessTokenStatusOptions } from "./token-status.js";
|
package/src/messages/en.ts
CHANGED
|
@@ -92,6 +92,8 @@ export const en = {
|
|
|
92
92
|
token_limit_reached: "You already hold the most active tokens allowed. Revoke one before creating another.",
|
|
93
93
|
token_not_found: "This token no longer exists. It may have been revoked already.",
|
|
94
94
|
grant_not_found: "This app is no longer connected.",
|
|
95
|
+
issue_refused: "Your account cannot create tokens right now.",
|
|
96
|
+
write_refused: "Your account cannot create tokens with write access. Create a read-only token instead.",
|
|
95
97
|
},
|
|
96
98
|
auth: {
|
|
97
99
|
unauthenticated: "Your session has ended. Log in again.",
|
package/src/messages/pl.ts
CHANGED
|
@@ -94,6 +94,8 @@ export const pl: typeof en = {
|
|
|
94
94
|
token_limit_reached: "Masz już najwięcej aktywnych tokenów, ile można. Unieważnij któryś, zanim utworzysz nowy.",
|
|
95
95
|
token_not_found: "Ten token już nie istnieje. Mógł zostać unieważniony.",
|
|
96
96
|
grant_not_found: "Ta aplikacja nie jest już połączona.",
|
|
97
|
+
issue_refused: "Twoje konto nie może teraz tworzyć tokenów.",
|
|
98
|
+
write_refused: "Twoje konto nie może tworzyć tokenów z prawem zapisu. Utwórz token tylko do odczytu.",
|
|
97
99
|
},
|
|
98
100
|
auth: {
|
|
99
101
|
unauthenticated: "Twoja sesja wygasła. Zaloguj się ponownie.",
|
package/src/next/actions.ts
CHANGED
|
@@ -4,18 +4,17 @@
|
|
|
4
4
|
// which the client controls, docs/02 §8) before the form is read, and every query is scoped to
|
|
5
5
|
// that owner. Unexpected failures become `safeError` codes; the token never reaches a log.
|
|
6
6
|
import { getCurrentUser } from "@softure-ai/auth/next";
|
|
7
|
-
import { errorLogLabel,
|
|
7
|
+
import { errorLogLabel, safeError, type SoftureConfig } from "@softure-ai/core";
|
|
8
8
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
9
9
|
import { revalidatePath } from "next/cache";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { getMcpClientSetup } from "../client-setup.js";
|
|
12
11
|
import type { IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
|
|
13
12
|
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
14
|
-
import {
|
|
13
|
+
import { getMcpAccessRoutes } from "../server/options.js";
|
|
15
14
|
import { revokeOAuthGrant } from "../server/oauth.js";
|
|
16
|
-
import {
|
|
17
|
-
import { getMcpAccessContext
|
|
18
|
-
import {
|
|
15
|
+
import { revokeAccessToken } from "../server/tokens.js";
|
|
16
|
+
import { getMcpAccessContext } from "./context.js";
|
|
17
|
+
import { issueToken } from "./issue-token.js";
|
|
19
18
|
|
|
20
19
|
/** Longer values are cut: the server functions refuse them anyway, and nothing huge is echoed back. */
|
|
21
20
|
const MAX_FIELD_LENGTH = MAX_TOKEN_NAME_LENGTH * 4;
|
|
@@ -24,11 +23,6 @@ const text = z
|
|
|
24
23
|
.catch("")
|
|
25
24
|
.transform((value) => value.slice(0, MAX_FIELD_LENGTH));
|
|
26
25
|
|
|
27
|
-
const issueInput = z.object({
|
|
28
|
-
name: text,
|
|
29
|
-
// A checked HTML checkbox sends "on"; an unchecked one sends nothing, which means read only.
|
|
30
|
-
canWrite: z.string().nullable().catch(null),
|
|
31
|
-
});
|
|
32
26
|
const revokeInput = z.object({ id: text });
|
|
33
27
|
|
|
34
28
|
function reportFailure(operation: string, error: unknown) {
|
|
@@ -40,40 +34,9 @@ function refreshPage(config: SoftureConfig): void {
|
|
|
40
34
|
revalidatePath(getMcpAccessRoutes(config).page);
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
/** Issues a token for the signed-in user and returns its plaintext with the setup snippets, once. */
|
|
44
|
-
export async function issueTokenAction(
|
|
45
|
-
|
|
46
|
-
const user = await getCurrentUser();
|
|
47
|
-
if (user === null) return { status: "error", error: "auth.unauthenticated" };
|
|
48
|
-
|
|
49
|
-
// Every field has a `catch`, so parsing cannot fail.
|
|
50
|
-
const input = issueInput.parse({ name: formData.get("name"), canWrite: formData.get("canWrite") });
|
|
51
|
-
try {
|
|
52
|
-
// Resolved before issuing: a failure afterwards would leave a token nobody received.
|
|
53
|
-
const endpointUrl = getMcpEndpointUrl(config, await getRequestOrigins(config, getMcpAccessRoutes(config).page));
|
|
54
|
-
const result = await issueAccessToken(await getMcpAccessContext(config), { userId: user.id, name: input.name, canWrite: input.canWrite !== null });
|
|
55
|
-
if (!result.ok) return { status: "error", error: result.error };
|
|
56
|
-
refreshPage(config);
|
|
57
|
-
const issued = result.value;
|
|
58
|
-
const messages = getMcpAccessMessages(config);
|
|
59
|
-
return {
|
|
60
|
-
status: "ok",
|
|
61
|
-
issued: {
|
|
62
|
-
id: issued.id,
|
|
63
|
-
name: issued.name,
|
|
64
|
-
canWrite: issued.canWrite,
|
|
65
|
-
expiresText: formatMessage(messages.issued.expires, { date: formatDate(config, issued.expiresAt) }),
|
|
66
|
-
setup: getMcpClientSetup({
|
|
67
|
-
serverName: getMcpAccessOptions(config).serverName,
|
|
68
|
-
endpointUrl,
|
|
69
|
-
token: issued.token,
|
|
70
|
-
promptTemplate: messages.setup.assistantPrompt,
|
|
71
|
-
}),
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
} catch (error) {
|
|
75
|
-
return { status: "error", error: reportFailure("issuing a token", error) };
|
|
76
|
-
}
|
|
37
|
+
/** Issues a token for the signed-in user and returns its plaintext with the setup snippets, once. No gate: see `issueToken`. */
|
|
38
|
+
export async function issueTokenAction(previous: IssueTokenFormState, formData: FormData): Promise<IssueTokenFormState> {
|
|
39
|
+
return issueToken(previous, formData);
|
|
77
40
|
}
|
|
78
41
|
|
|
79
42
|
/** Revokes one of the signed-in user's tokens; another account's id matches nothing. */
|
package/src/next/format.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Dates on the token page, in the app's locale and time zone.
|
|
2
|
-
import type
|
|
2
|
+
import { formatCalendarDay, toCalendarDay, type SoftureConfig } from "@softure-ai/core";
|
|
3
3
|
|
|
4
4
|
export function formatDate(config: SoftureConfig, date: Date): string {
|
|
5
|
-
return
|
|
5
|
+
return formatCalendarDay(toCalendarDay(date, config.timezone), config.locale, "long");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function formatDateTime(config: SoftureConfig, date: Date): string {
|
package/src/next/index.ts
CHANGED
|
@@ -11,5 +11,6 @@ export {
|
|
|
11
11
|
registerOAuthClientRoute,
|
|
12
12
|
} from "./oauth-routes.js";
|
|
13
13
|
export { getMcpAccessContext, getRequestOrigins } from "./context.js";
|
|
14
|
-
export {
|
|
14
|
+
export { issueToken, type BeforeIssueGate, type IssueGateInput, type IssueTokenOptions } from "./issue-token.js";
|
|
15
|
+
export { McpAccessPage, type McpAccessPageProps } from "./pages.js";
|
|
15
16
|
export { createMcpRoute } from "./route.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Issuing a token from the token page's form, with an optional gate the app puts in front of it (a
|
|
2
|
+
// billing write gate). Not a server action itself: the app calls it from its own `"use server"` file,
|
|
3
|
+
// so the gate stays in the app's code and out of softure.config.ts:
|
|
4
|
+
//
|
|
5
|
+
// "use server";
|
|
6
|
+
// export async function issueGatedToken(previous: IssueTokenFormState, form: FormData) {
|
|
7
|
+
// return issueToken(previous, form, { beforeIssue: checkPlan });
|
|
8
|
+
// }
|
|
9
|
+
//
|
|
10
|
+
// The owner comes from the session (never from a bound argument, which the client controls, docs/02 §8)
|
|
11
|
+
// before the form is read. Unexpected failures, the gate's included, become `safeError` codes.
|
|
12
|
+
import { getCurrentUser } from "@softure-ai/auth/next";
|
|
13
|
+
import { errorLogLabel, formatMessage, safeError, type Err, type Ok } from "@softure-ai/core";
|
|
14
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
15
|
+
import { revalidatePath } from "next/cache";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { getMcpClientSetup } from "../client-setup.js";
|
|
18
|
+
import type { IssueTokenFormState, McpIssueRefusalCode } from "../contract.js";
|
|
19
|
+
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
20
|
+
import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
|
|
21
|
+
import { issueAccessToken } from "../server/tokens.js";
|
|
22
|
+
import { getMcpAccessContext, getRequestOrigins } from "./context.js";
|
|
23
|
+
import { formatDate } from "./format.js";
|
|
24
|
+
|
|
25
|
+
/** Longer values are cut: the server functions refuse them anyway, and nothing huge is echoed back. */
|
|
26
|
+
const MAX_FIELD_LENGTH = MAX_TOKEN_NAME_LENGTH * 4;
|
|
27
|
+
|
|
28
|
+
const issueInput = z.object({
|
|
29
|
+
name: z
|
|
30
|
+
.string()
|
|
31
|
+
.catch("")
|
|
32
|
+
.transform((value) => value.slice(0, MAX_FIELD_LENGTH)),
|
|
33
|
+
// A checked HTML checkbox sends "on"; an unchecked one sends nothing, which means read only.
|
|
34
|
+
canWrite: z.string().nullable().catch(null),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** What the gate learns about the token about to be issued. */
|
|
38
|
+
export interface IssueGateInput {
|
|
39
|
+
readonly userId: string;
|
|
40
|
+
readonly canWrite: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The app's check before a token is issued: `ok()` lets it through, `err(code)` refuses with a message the page shows. */
|
|
44
|
+
export type BeforeIssueGate = (input: IssueGateInput) => Ok<unknown> | Err<McpIssueRefusalCode> | Promise<Ok<unknown> | Err<McpIssueRefusalCode>>;
|
|
45
|
+
|
|
46
|
+
export interface IssueTokenOptions {
|
|
47
|
+
readonly beforeIssue?: BeforeIssueGate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Issues a token for the signed-in user, after the gate, and returns its plaintext with the setup snippets, once. */
|
|
51
|
+
export async function issueToken(_previous: IssueTokenFormState, formData: FormData, options: IssueTokenOptions = {}): Promise<IssueTokenFormState> {
|
|
52
|
+
const config = getSoftureConfig();
|
|
53
|
+
const user = await getCurrentUser();
|
|
54
|
+
if (user === null) return { status: "error", error: "auth.unauthenticated" };
|
|
55
|
+
|
|
56
|
+
// Every field has a `catch`, so parsing cannot fail.
|
|
57
|
+
const input = issueInput.parse({ name: formData.get("name"), canWrite: formData.get("canWrite") });
|
|
58
|
+
const canWrite = input.canWrite !== null;
|
|
59
|
+
try {
|
|
60
|
+
if (options.beforeIssue !== undefined) {
|
|
61
|
+
const gate = await options.beforeIssue({ userId: user.id, canWrite });
|
|
62
|
+
if (!gate.ok) return { status: "error", error: gate.error };
|
|
63
|
+
}
|
|
64
|
+
// Resolved before issuing: a failure afterwards would leave a token nobody received.
|
|
65
|
+
const endpointUrl = getMcpEndpointUrl(config, await getRequestOrigins(config, getMcpAccessRoutes(config).page));
|
|
66
|
+
const result = await issueAccessToken(await getMcpAccessContext(config), { userId: user.id, name: input.name, canWrite });
|
|
67
|
+
if (!result.ok) return { status: "error", error: result.error };
|
|
68
|
+
revalidatePath(getMcpAccessRoutes(config).page);
|
|
69
|
+
const issued = result.value;
|
|
70
|
+
const messages = getMcpAccessMessages(config);
|
|
71
|
+
return {
|
|
72
|
+
status: "ok",
|
|
73
|
+
issued: {
|
|
74
|
+
id: issued.id,
|
|
75
|
+
name: issued.name,
|
|
76
|
+
canWrite: issued.canWrite,
|
|
77
|
+
expiresText: formatMessage(messages.issued.expires, { date: formatDate(config, issued.expiresAt) }),
|
|
78
|
+
setup: getMcpClientSetup({
|
|
79
|
+
serverName: getMcpAccessOptions(config).serverName,
|
|
80
|
+
endpointUrl,
|
|
81
|
+
token: issued.token,
|
|
82
|
+
promptTemplate: messages.setup.assistantPrompt,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`@softure-ai/mcp-access: issuing a token failed: ${errorLogLabel(error)}`);
|
|
88
|
+
return { status: "error", error: safeError(error).error };
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/next/pages.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAcce
|
|
|
14
14
|
import { listOAuthGrants } from "../server/oauth.js";
|
|
15
15
|
import { listAccessTokens } from "../server/tokens.js";
|
|
16
16
|
import { getAccessTokenStatus } from "../token-status.js";
|
|
17
|
-
import { TokenManager, type TokenManagerGrant, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
|
|
17
|
+
import { TokenManager, type IssueTokenAction, type TokenManagerGrant, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
|
|
18
18
|
import { issueTokenAction, revokeGrantAction, revokeTokenAction } from "./actions.js";
|
|
19
19
|
import { getMcpAccessContext } from "./context.js";
|
|
20
20
|
import { formatDate, formatDateTime } from "./format.js";
|
|
@@ -64,7 +64,15 @@ function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig,
|
|
|
64
64
|
return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export
|
|
67
|
+
export interface McpAccessPageProps {
|
|
68
|
+
/**
|
|
69
|
+
* The app's own issue action, e.g. one that calls `issueToken` with a `beforeIssue` gate. Default:
|
|
70
|
+
* `issueTokenAction`, which issues without a gate.
|
|
71
|
+
*/
|
|
72
|
+
readonly issueAction?: IssueTokenAction;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function McpAccessPage({ issueAction = issueTokenAction }: McpAccessPageProps = {}) {
|
|
68
76
|
const config = getSoftureConfig();
|
|
69
77
|
const user = await requireUser({ next: getMcpAccessRoutes(config).page });
|
|
70
78
|
const options = getMcpAccessOptions(config);
|
|
@@ -81,7 +89,7 @@ export async function McpAccessPage() {
|
|
|
81
89
|
tokens={tokens.map((token) => toRow(token, now, config, options, messages))}
|
|
82
90
|
allowWrites={options.allowWrites}
|
|
83
91
|
maxTokens={options.maxTokensPerUser}
|
|
84
|
-
issueAction={
|
|
92
|
+
issueAction={issueAction}
|
|
85
93
|
revokeAction={revokeTokenAction}
|
|
86
94
|
{...(grants === null ? {} : { grants: grants.map((grant) => toGrant(grant, config, options, messages)), revokeGrantAction })}
|
|
87
95
|
messages={messages}
|
package/src/options.ts
CHANGED
|
@@ -28,6 +28,10 @@ const toolSchema = z.strictObject({
|
|
|
28
28
|
access: z.enum(["read", "write"]),
|
|
29
29
|
/** What the tool does, for the owner deciding which token to issue. */
|
|
30
30
|
description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
|
|
31
|
+
/** A short display name (MCP's `title`), for clients and agent docs that show one. */
|
|
32
|
+
title: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text").optional(),
|
|
33
|
+
/** A request a person could make that uses the tool, e.g. "Show my last five orders.", for agent docs. */
|
|
34
|
+
example: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text").optional(),
|
|
31
35
|
});
|
|
32
36
|
|
|
33
37
|
/**
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The routes the module mounts, as constants for code that cannot read the config: a proxy matcher,
|
|
2
|
+
// a client, a test. The manifest declares the same object.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MCP_ACCESS_ROUTES = {
|
|
5
|
+
/** The token page. */
|
|
6
|
+
page: "/account/mcp",
|
|
7
|
+
/** `POST` MCP endpoint. */
|
|
8
|
+
endpoint: "/api/mcp",
|
|
9
|
+
/** OAuth consent page, the authorization endpoint. */
|
|
10
|
+
oauthConsent: "/oauth/authorize",
|
|
11
|
+
/** `POST` target of the consent form. */
|
|
12
|
+
oauthDecision: "/api/oauth/authorize",
|
|
13
|
+
/** `POST` OAuth token endpoint. */
|
|
14
|
+
oauthToken: "/api/oauth/token",
|
|
15
|
+
/** `POST` dynamic client registration. */
|
|
16
|
+
oauthRegister: "/api/oauth/register",
|
|
17
|
+
} as const;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A server action as a write tool: the tool's arguments become the form the action reads, so the tool
|
|
2
|
+
// and the form share one validation path, and the action's error state becomes a tool error.
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
4
|
+
import { describeCode, toolError, toolResult, withToolErrors, type ToolErrorHints } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
/** The action, called with the form. A `useActionState` action is passed as `(form) => action(INITIAL, form)`. */
|
|
7
|
+
export type FormActionHandler = (formData: FormData) => unknown;
|
|
8
|
+
|
|
9
|
+
export interface ActionToolOptions {
|
|
10
|
+
/** The answer on success: a text, or a text built from the action's state. Default: the state as JSON. */
|
|
11
|
+
readonly message?: string | ((state: unknown) => string);
|
|
12
|
+
/** Texts per error code, as in `withToolErrors`. */
|
|
13
|
+
readonly hints?: ToolErrorHints;
|
|
14
|
+
/** The tool's name in the log line. */
|
|
15
|
+
readonly label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The tool callback for `server.registerTool(name, { inputSchema }, actionTool(handler))`. */
|
|
19
|
+
export function actionTool(handler: FormActionHandler, options: ActionToolOptions = {}): (args: Readonly<Record<string, unknown>>) => Promise<CallToolResult> {
|
|
20
|
+
const hints = options.hints ?? {};
|
|
21
|
+
return (args) =>
|
|
22
|
+
withToolErrors(
|
|
23
|
+
async () => {
|
|
24
|
+
const state = await handler(toFormData(args));
|
|
25
|
+
const error = readErrorCode(state);
|
|
26
|
+
if (error !== null) return toolError(describeCode(error, hints));
|
|
27
|
+
if (options.message === undefined) return toolResult(state);
|
|
28
|
+
return toolResult(typeof options.message === "string" ? options.message : options.message(state));
|
|
29
|
+
},
|
|
30
|
+
{ hints, ...(options.label === undefined ? {} : { label: options.label }) },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Arguments as a submitted form: strings as they are, numbers as text, `true` as `"on"` (a checked
|
|
36
|
+
* checkbox) and `false`, `null` and `undefined` left out (an unchecked one), arrays one entry per item,
|
|
37
|
+
* dates as ISO text, objects as JSON.
|
|
38
|
+
*/
|
|
39
|
+
export function toFormData(args: Readonly<Record<string, unknown>>): FormData {
|
|
40
|
+
const form = new FormData();
|
|
41
|
+
for (const [name, value] of Object.entries(args)) {
|
|
42
|
+
for (const item of Array.isArray(value) ? (value as unknown[]) : [value]) {
|
|
43
|
+
const field = toFieldValue(item);
|
|
44
|
+
if (field !== null) form.append(name, field);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return form;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toFieldValue(value: unknown): string | null {
|
|
51
|
+
if (value === undefined || value === null || value === false) return null;
|
|
52
|
+
if (value === true) return "on";
|
|
53
|
+
if (typeof value === "string") return value;
|
|
54
|
+
if (typeof value === "number" || typeof value === "bigint") return value.toString();
|
|
55
|
+
if (value instanceof Date) return value.toISOString();
|
|
56
|
+
return JSON.stringify(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The error code of an action state: `{ ok: false, error }` (core's result) or `{ status: "error", error }` (a form state). */
|
|
60
|
+
function readErrorCode(state: unknown): string | null {
|
|
61
|
+
if (typeof state !== "object" || state === null || !("error" in state) || typeof state.error !== "string") return null;
|
|
62
|
+
const isFailure = ("ok" in state && state.ok === false) || ("status" in state && state.status === "error");
|
|
63
|
+
return isFailure ? state.error : null;
|
|
64
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
type McpEndpointOptions,
|
|
11
11
|
type McpServerFactory,
|
|
12
12
|
} from "./endpoint.js";
|
|
13
|
+
export { actionTool, toFormData, type ActionToolOptions, type FormActionHandler } from "./action-tool.js";
|
|
13
14
|
export { createMcpAccessContext } from "./context.js";
|
|
14
15
|
export { checkAccessTokensTable } from "./health.js";
|
|
15
16
|
export {
|
|
@@ -68,6 +69,7 @@ export type { OAuthClientRow, OAuthTokenEndpointAuthMethod } from "../schema.js"
|
|
|
68
69
|
export { describeRedirectUri, isAllowedRedirectUri, MAX_CLIENT_NAME_LENGTH, parseClientRegistration, type ClientRegistrationResult, type RegistrationError } from "./oauth-validation.js";
|
|
69
70
|
export { getCodeChallenge, isPkceVerifierValid, isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
|
|
70
71
|
export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
|
|
72
|
+
export { pruneMcpAccess, type PrunedMcpAccessRecords } from "./prune.js";
|
|
71
73
|
export {
|
|
72
74
|
deleteMcpAccessUserData,
|
|
73
75
|
exportMcpAccessUserData,
|
|
@@ -90,3 +92,4 @@ export {
|
|
|
90
92
|
type RevokeAccessTokenInput,
|
|
91
93
|
type VerifiedAccessToken,
|
|
92
94
|
} from "./tokens.js";
|
|
95
|
+
export { toolError, toolResult, withToolErrors, type ToolErrorHints, type ToolErrorOptions } from "./tools.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The module's scheduled cleanup: expired access tokens and dead OAuth records of every account, in
|
|
2
|
+
// one call for a daily job (`softure-mcp prune`, a deploy `maintain` hook) instead of inline on requests.
|
|
3
|
+
import { pruneOAuthRecords, type PrunedOAuthRecords } from "./oauth.js";
|
|
4
|
+
import { pruneAccessTokens, type McpAccessContext } from "./tokens.js";
|
|
5
|
+
|
|
6
|
+
export interface PrunedMcpAccessRecords extends PrunedOAuthRecords {
|
|
7
|
+
readonly accessTokens: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Runs `pruneAccessTokens`, then `pruneOAuthRecords`, and counts what each removed. */
|
|
11
|
+
export async function pruneMcpAccess(ctx: McpAccessContext): Promise<PrunedMcpAccessRecords> {
|
|
12
|
+
const accessTokens = await pruneAccessTokens(ctx);
|
|
13
|
+
return { accessTokens, ...(await pruneOAuthRecords(ctx)) };
|
|
14
|
+
}
|