@softure-ai/mcp-access 0.1.11 → 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 +32 -0
- package/README.md +136 -11
- 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 +3 -2
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +2 -1
- 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 +7 -3
- package/dist/options.js.map +1 -1
- package/dist/origins.d.ts +3 -14
- package/dist/origins.d.ts.map +1 -1
- package/dist/origins.js +4 -20
- package/dist/origins.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/oauth-http.js +1 -1
- package/dist/server/oauth-http.js.map +1 -1
- package/dist/server/origins.d.ts +1 -1
- package/dist/server/origins.d.ts.map +1 -1
- package/dist/server/origins.js +7 -2
- package/dist/server/origins.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 +3 -2
- package/src/next/issue-token.ts +90 -0
- package/src/next/pages.tsx +11 -3
- package/src/options.ts +7 -3
- package/src/origins.ts +6 -27
- package/src/routes.ts +17 -0
- package/src/server/action-tool.ts +64 -0
- package/src/server/index.ts +3 -0
- package/src/server/oauth-http.ts +1 -1
- package/src/server/origins.ts +6 -5
- 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
|
@@ -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
|
/**
|
|
@@ -137,9 +141,9 @@ export const mcpAccessOptionsSchema = z
|
|
|
137
141
|
*/
|
|
138
142
|
legacyTokenPattern: legacyTokenPatternSchema.optional(),
|
|
139
143
|
/**
|
|
140
|
-
* The app origin of a request, when
|
|
141
|
-
*
|
|
142
|
-
* `readRequestOrigin`
|
|
144
|
+
* The app origin of a request, when the config's `origins` block (core `resolveAppOrigin`) does not
|
|
145
|
+
* answer it: prefer `origins.trustedOrigins` or `origins.trustRequestHost` there, which every
|
|
146
|
+
* module reads. `readRequestOrigin` reads it from `Host`. Null falls back to the config's rule.
|
|
143
147
|
*/
|
|
144
148
|
resolveAppOrigin: z.custom<McpAppOriginResolver>((value) => typeof value === "function", "must be a function (request) => string | null").optional(),
|
|
145
149
|
/**
|
package/src/origins.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// The origins one request is served under: the app origin every OAuth URL is built from, and the
|
|
2
2
|
// other public hosts whose root may be named as a protected resource. Pure: no config, no database.
|
|
3
|
+
// Reading the request is core's rule (`readRequestHost`, `readRequestOrigin`, issue #311).
|
|
4
|
+
import { readForwardedProto, readRequestHost, readRequestOrigin, type OriginRequest } from "@softure-ai/core";
|
|
5
|
+
|
|
6
|
+
export { readRequestHost, readRequestOrigin };
|
|
3
7
|
|
|
4
8
|
/** What the origin helpers read from a request: a Web `Request`, or Next's `headers()` with a URL. */
|
|
5
|
-
export
|
|
6
|
-
readonly url: string;
|
|
7
|
-
readonly headers: { get(name: string): string | null };
|
|
8
|
-
}
|
|
9
|
+
export type McpOriginRequest = OriginRequest;
|
|
9
10
|
|
|
10
11
|
export interface McpOrigins {
|
|
11
12
|
/** The issuer, and the origin of every endpoint, of `resource_metadata` and of the consent form. */
|
|
@@ -14,28 +15,6 @@ export interface McpOrigins {
|
|
|
14
15
|
readonly resourceOrigins: readonly string[];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function firstValue(header: string | null): string | null {
|
|
18
|
-
const value = header?.split(",")[0]?.trim().toLowerCase();
|
|
19
|
-
return value === undefined || value === "" ? null : value;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The host the request was sent to: `Host`, else the URL's host. Behind a proxy the URL names the server's own. */
|
|
23
|
-
export function readRequestHost(request: McpOriginRequest): string {
|
|
24
|
-
return firstValue(request.headers.get("host")) ?? new URL(request.url).host;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The origin the request was sent to: the first `X-Forwarded-Proto` value when it is http or https
|
|
29
|
-
* (else the URL's scheme) and `Host` (else the URL's host). For `resolveAppOrigin` in an app whose
|
|
30
|
-
* proxy passes `Host` through and refuses hosts it does not serve.
|
|
31
|
-
*/
|
|
32
|
-
export function readRequestOrigin(request: McpOriginRequest): string {
|
|
33
|
-
const url = new URL(request.url);
|
|
34
|
-
const proto = firstValue(request.headers.get("x-forwarded-proto"));
|
|
35
|
-
const scheme = proto === "http" || proto === "https" ? proto : url.protocol.replace(/:$/, "");
|
|
36
|
-
return new URL(`${scheme}://${readRequestHost(request)}`).origin;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
18
|
/**
|
|
40
19
|
* The resource origin the request was sent to, when its host is one of `resourceOrigins`; when two
|
|
41
20
|
* share the host, the one whose scheme `X-Forwarded-Proto` names wins, else the first. Null otherwise.
|
|
@@ -43,6 +22,6 @@ export function readRequestOrigin(request: McpOriginRequest): string {
|
|
|
43
22
|
export function findServedResourceOrigin(origins: McpOrigins, request: McpOriginRequest): string | null {
|
|
44
23
|
const host = readRequestHost(request);
|
|
45
24
|
const candidates = origins.resourceOrigins.filter((origin) => new URL(origin).host === host);
|
|
46
|
-
const proto =
|
|
25
|
+
const proto = readForwardedProto(request);
|
|
47
26
|
return candidates.find((origin) => new URL(origin).protocol === `${proto ?? ""}:`) ?? candidates[0] ?? null;
|
|
48
27
|
}
|
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";
|
package/src/server/oauth-http.ts
CHANGED
|
@@ -53,7 +53,7 @@ const CORS_HEADERS = {
|
|
|
53
53
|
/** Responses that carry credentials must not be stored (RFC 6749 §5.1). */
|
|
54
54
|
const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" };
|
|
55
55
|
/** Discovery documents change only with a deploy, and with the host they were asked on. */
|
|
56
|
-
const DISCOVERY_CACHE = { "cache-control": "public, max-age=300", vary: "host, x-forwarded-proto" };
|
|
56
|
+
const DISCOVERY_CACHE = { "cache-control": "public, max-age=300", vary: "host, x-forwarded-host, x-forwarded-proto" };
|
|
57
57
|
|
|
58
58
|
function protocolJson(body: unknown, status = 200, extra: Readonly<Record<string, string>> = {}): Response {
|
|
59
59
|
return Response.json(body, { status, headers: { ...CORS_HEADERS, ...NO_STORE, ...extra } });
|
package/src/server/origins.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The origins of one request
|
|
2
|
-
//
|
|
3
|
-
import type
|
|
1
|
+
// The origins of one request: the `resolveAppOrigin` option when it answers, else core `resolveAppOrigin` (a listed
|
|
2
|
+
// origin the request was sent to, see the config's `origins`, else `appOrigin`), plus `resourceOrigins`.
|
|
3
|
+
import { resolveAppOrigin, type SoftureConfig } from "@softure-ai/core";
|
|
4
4
|
import { isBareOrigin } from "../options.js";
|
|
5
5
|
import type { McpOriginRequest, McpOrigins } from "../origins.js";
|
|
6
6
|
import { getMcpAccessOptions } from "./options.js";
|
|
@@ -11,9 +11,10 @@ import { getMcpAccessOptions } from "./options.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export function resolveMcpOrigins(config: SoftureConfig, request?: McpOriginRequest | null): McpOrigins {
|
|
13
13
|
const options = getMcpAccessOptions(config);
|
|
14
|
-
|
|
14
|
+
if (request === undefined || request === null) return { appOrigin: config.appOrigin, resourceOrigins: options.resourceOrigins };
|
|
15
|
+
const resolved = options.resolveAppOrigin === undefined ? null : options.resolveAppOrigin(request);
|
|
15
16
|
if (resolved !== null && !isBareOrigin(resolved)) {
|
|
16
17
|
throw new Error(`@softure-ai/mcp-access: resolveAppOrigin returned "${resolved.slice(0, 100)}", which is not an http(s) origin without a path`);
|
|
17
18
|
}
|
|
18
|
-
return { appOrigin: new URL(resolved ?? config
|
|
19
|
+
return { appOrigin: new URL(resolved ?? resolveAppOrigin(config, request)).origin, resourceOrigins: options.resourceOrigins };
|
|
19
20
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Tool results for the app's MCP server and the boundary that keeps failures safe: the assistant gets
|
|
2
|
+
// a value, a `PublicError`'s message or a hint, never a thrown error's text (a failed query carries
|
|
3
|
+
// its SQL and parameters). The log gets the error's kind only (`errorLogLabel`).
|
|
4
|
+
import { isCallToolResult, type CallToolResult } from "@modelcontextprotocol/server";
|
|
5
|
+
import { errorLogLabel, getPublicMessage, safeError } from "@softure-ai/core";
|
|
6
|
+
|
|
7
|
+
/** What the assistant reads for an error code: the app's own text per code, e.g. `{ "orders.not_found": "No order with that number." }`. */
|
|
8
|
+
export type ToolErrorHints = Readonly<Record<string, string>>;
|
|
9
|
+
|
|
10
|
+
export interface ToolErrorOptions {
|
|
11
|
+
/** Texts per error code; a code without one is answered as the code itself. */
|
|
12
|
+
readonly hints?: ToolErrorHints;
|
|
13
|
+
/** The tool's name in the log line. */
|
|
14
|
+
readonly label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** For failures the tool did not expect: what `safeError` makes of them, unless a hint replaces it. */
|
|
18
|
+
const DEFAULT_FAILURE_TEXT: ToolErrorHints = {
|
|
19
|
+
"core.database_failed": "The database could not answer; try again later.",
|
|
20
|
+
"core.unexpected": "The tool failed; try again later.",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A successful tool answer: a string as text, anything else as JSON text. */
|
|
24
|
+
export function toolResult(value: unknown): CallToolResult {
|
|
25
|
+
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) ?? "null" }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A failed tool answer the assistant reads and may act on. */
|
|
29
|
+
export function toolError(message: string): CallToolResult {
|
|
30
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs a tool's work and answers it safely. A `CallToolResult` passes as is; a result value is unwrapped
|
|
35
|
+
* (`{ ok: true, value }` → `toolResult(value)`, `{ ok: false, error }` → `toolError` with the hint for the
|
|
36
|
+
* code, else the code); any other value becomes `toolResult`. A thrown `PublicError` gives its message;
|
|
37
|
+
* anything else is logged by kind and answered with the hint for its `safeError` code, else a fixed text.
|
|
38
|
+
*/
|
|
39
|
+
export async function withToolErrors(work: () => unknown, options: ToolErrorOptions = {}): Promise<CallToolResult> {
|
|
40
|
+
try {
|
|
41
|
+
return answerValue(await work(), options.hints);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return answerFailure(error, options);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer for a value a tool or an action returned. */
|
|
48
|
+
export function answerValue(value: unknown, hints: ToolErrorHints = {}): CallToolResult {
|
|
49
|
+
if (isCallToolResult(value)) return value;
|
|
50
|
+
if (isResult(value)) return value.ok ? toolResult(value.value) : toolError(describeCode(value.error, hints));
|
|
51
|
+
return toolResult(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function answerFailure(error: unknown, options: ToolErrorOptions): CallToolResult {
|
|
55
|
+
const publicMessage = getPublicMessage(error);
|
|
56
|
+
if (publicMessage !== null) return toolError(publicMessage);
|
|
57
|
+
console.error(`@softure-ai/mcp-access: tool ${options.label ?? "call"} failed: ${errorLogLabel(error)}`);
|
|
58
|
+
const code = safeError(error).error;
|
|
59
|
+
return toolError(options.hints?.[code] ?? DEFAULT_FAILURE_TEXT[code] ?? code);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The text for an error code: the app's hint, else the code. */
|
|
63
|
+
export function describeCode(code: string, hints: ToolErrorHints): string {
|
|
64
|
+
return Object.hasOwn(hints, code) ? (hints[code] ?? code) : code;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Core's result shape: `{ ok: true, value }` or `{ ok: false, error }` with a string code. */
|
|
68
|
+
function isResult(value: unknown): value is { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly error: string } {
|
|
69
|
+
if (typeof value !== "object" || value === null || !("ok" in value)) return false;
|
|
70
|
+
if (value.ok === true) return "value" in value;
|
|
71
|
+
return value.ok === false && "error" in value && typeof value.error === "string";
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// `@softure-ai/mcp-access/stdio`: the app's MCP server over stdio for a local assistant, with the
|
|
2
|
+
// account from the environment. `/stdio/register` lets a factory that imports `server-only` run here.
|
|
3
|
+
export {
|
|
4
|
+
DEFAULT_MCP_USER_ID_ENV,
|
|
5
|
+
serveMcpStdio,
|
|
6
|
+
STDIO_TOKEN_ID,
|
|
7
|
+
type McpStdioError,
|
|
8
|
+
type McpStdioErrorCode,
|
|
9
|
+
type McpStdioHandle,
|
|
10
|
+
type McpStdioResult,
|
|
11
|
+
type ServeMcpStdioOptions,
|
|
12
|
+
} from "./serve.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// `node --import @softure-ai/mcp-access/stdio/register` (or `tsx --import …`): resolves `server-only` to
|
|
2
|
+
// an empty module, so an MCP server factory written for Next (which marks its files with
|
|
3
|
+
// `import "server-only"`) runs in a plain Node process. Nothing else is resolved differently; unlike
|
|
4
|
+
// `--conditions=react-server`, React keeps its regular build.
|
|
5
|
+
import { registerHooks } from "node:module";
|
|
6
|
+
|
|
7
|
+
const EMPTY_MODULE = "data:text/javascript,export%20%7B%7D%3B";
|
|
8
|
+
|
|
9
|
+
registerHooks({
|
|
10
|
+
resolve: (specifier, context, nextResolve) => (specifier === "server-only" ? { url: EMPTY_MODULE, format: "module", shortCircuit: true } : nextResolve(specifier, context)),
|
|
11
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The app's MCP server over stdio, for a local assistant (Claude Code, Claude Desktop) on the machine
|
|
2
|
+
// that holds the database. There is no token: the account comes from the environment or, on a
|
|
3
|
+
// database with exactly one account, is that account. The app's factory is the one the endpoint uses.
|
|
4
|
+
//
|
|
5
|
+
// // scripts/mcp-stdio.ts, run with `tsx --import @softure-ai/mcp-access/stdio/register scripts/mcp-stdio.ts`
|
|
6
|
+
// const started = await serveMcpStdio({ config, createServer });
|
|
7
|
+
// if (!started.ok) { console.error(started.error.message); process.exitCode = 1; }
|
|
8
|
+
import type { Transport } from "@modelcontextprotocol/server";
|
|
9
|
+
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
10
|
+
import { users } from "@softure-ai/auth";
|
|
11
|
+
import { errorLogLabel, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
12
|
+
import { openCommandDatabase, type CommandDatabase } from "@softure-ai/db";
|
|
13
|
+
import { eq } from "drizzle-orm";
|
|
14
|
+
import type { McpServerIdentity } from "../contract.js";
|
|
15
|
+
import type { McpServerFactory } from "../server/endpoint.js";
|
|
16
|
+
import { getMcpAccessOptions } from "../server/options.js";
|
|
17
|
+
|
|
18
|
+
/** The variable naming the account to serve, unless the entry is told another one. */
|
|
19
|
+
export const DEFAULT_MCP_USER_ID_ENV = "MCP_USER_ID";
|
|
20
|
+
|
|
21
|
+
/** The token id the factory receives over stdio, where there is no token. */
|
|
22
|
+
export const STDIO_TOKEN_ID = "stdio";
|
|
23
|
+
|
|
24
|
+
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
|
|
26
|
+
export type McpStdioErrorCode = "mcp-access.stdio_no_database" | "mcp-access.stdio_no_account" | "mcp-access.stdio_account_ambiguous" | "mcp-access.stdio_account_unknown";
|
|
27
|
+
|
|
28
|
+
/** Why the entry did not start, with a message for the person running it (stderr: stdout is the protocol). */
|
|
29
|
+
export interface McpStdioError {
|
|
30
|
+
readonly code: McpStdioErrorCode;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface McpStdioHandle {
|
|
35
|
+
/** Who the server runs as. */
|
|
36
|
+
readonly identity: McpServerIdentity;
|
|
37
|
+
/** Ends the connection and closes the database. Also happens when the client closes stdin. */
|
|
38
|
+
readonly close: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type McpStdioResult = { readonly ok: true; readonly value: McpStdioHandle } | { readonly ok: false; readonly error: McpStdioError };
|
|
42
|
+
|
|
43
|
+
export interface ServeMcpStdioOptions {
|
|
44
|
+
readonly config: SoftureConfig;
|
|
45
|
+
readonly createServer: McpServerFactory;
|
|
46
|
+
/** The environment variable with the account's id. Default: `MCP_USER_ID`. */
|
|
47
|
+
readonly userIdEnv?: string;
|
|
48
|
+
/** Registers write tools, when the app's `allowWrites` is on too. Default: false, read only. */
|
|
49
|
+
readonly allowWrites?: boolean;
|
|
50
|
+
/** Default: `process.env`. */
|
|
51
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
52
|
+
/** Default: stdin and stdout of this process. */
|
|
53
|
+
readonly transport?: Transport;
|
|
54
|
+
/** Default: the config's `database.handle`, else a connection on `database.url`. */
|
|
55
|
+
readonly openDatabase?: (database: SoftureDatabaseConfig) => Promise<CommandDatabase>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolves the account, then serves the app's server for it over stdio until the client goes away. */
|
|
59
|
+
export async function serveMcpStdio(options: ServeMcpStdioOptions): Promise<McpStdioResult> {
|
|
60
|
+
const { config } = options;
|
|
61
|
+
if (config.database === null) return refuse("mcp-access.stdio_no_database", "softure.config.ts has no database; mcp-access needs one");
|
|
62
|
+
const userIdEnv = options.userIdEnv ?? DEFAULT_MCP_USER_ID_ENV;
|
|
63
|
+
const env = options.env ?? process.env;
|
|
64
|
+
const database = await (options.openDatabase ?? ((settings) => openCommandDatabase(settings, { max: 1 })))(config.database);
|
|
65
|
+
|
|
66
|
+
let userId: string | McpStdioError;
|
|
67
|
+
try {
|
|
68
|
+
userId = await resolveAccount(database, env[userIdEnv]?.trim() ?? "", userIdEnv);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await database.close();
|
|
71
|
+
throw new Error(`@softure-ai/mcp-access: looking up the account to serve failed: ${errorLogLabel(error)}`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
if (typeof userId !== "string") {
|
|
74
|
+
await database.close();
|
|
75
|
+
return { ok: false, error: userId };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const identity: McpServerIdentity = {
|
|
79
|
+
userId,
|
|
80
|
+
canWrite: (options.allowWrites ?? false) && getMcpAccessOptions(config).allowWrites,
|
|
81
|
+
tokenId: STDIO_TOKEN_ID,
|
|
82
|
+
};
|
|
83
|
+
let closed = false;
|
|
84
|
+
const closeDatabase = async () => {
|
|
85
|
+
if (closed) return;
|
|
86
|
+
closed = true;
|
|
87
|
+
await database.close();
|
|
88
|
+
};
|
|
89
|
+
const transport = options.transport ?? new StdioServerTransport();
|
|
90
|
+
const handle = serveStdio(() => options.createServer(identity), {
|
|
91
|
+
transport,
|
|
92
|
+
onerror: (error) => console.error(`@softure-ai/mcp-access: the stdio connection reported: ${errorLogLabel(error)}`),
|
|
93
|
+
});
|
|
94
|
+
// serveStdio has set its own handler by now; the database follows the connection, so a client that
|
|
95
|
+
// closes stdin leaves no open pool keeping the process alive.
|
|
96
|
+
const servedOnClose = transport.onclose;
|
|
97
|
+
transport.onclose = () => {
|
|
98
|
+
servedOnClose?.();
|
|
99
|
+
void closeDatabase();
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
value: {
|
|
104
|
+
identity,
|
|
105
|
+
close: async () => {
|
|
106
|
+
await handle.close();
|
|
107
|
+
await closeDatabase();
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The account id, or why there is none. Never the first of several accounts: that could be another person's data. */
|
|
114
|
+
async function resolveAccount(database: CommandDatabase, requested: string, userIdEnv: string): Promise<string | McpStdioError> {
|
|
115
|
+
const { db } = database.handle;
|
|
116
|
+
if (requested !== "") {
|
|
117
|
+
const unknown = { code: "mcp-access.stdio_account_unknown", message: `@softure-ai/mcp-access: ${userIdEnv} names no account` } as const;
|
|
118
|
+
if (!UUID_SHAPE.test(requested)) return unknown;
|
|
119
|
+
const found = await db.select({ id: users.id }).from(users).where(eq(users.id, requested)).limit(1);
|
|
120
|
+
return found[0]?.id ?? unknown;
|
|
121
|
+
}
|
|
122
|
+
const accounts = await db.select({ id: users.id }).from(users).limit(2);
|
|
123
|
+
const [only, second] = accounts;
|
|
124
|
+
if (only === undefined) return { code: "mcp-access.stdio_no_account", message: "@softure-ai/mcp-access: the database has no account to serve" };
|
|
125
|
+
if (second !== undefined) {
|
|
126
|
+
return { code: "mcp-access.stdio_account_ambiguous", message: `@softure-ai/mcp-access: the database has several accounts; set ${userIdEnv} to the id of the one to serve` };
|
|
127
|
+
}
|
|
128
|
+
return only.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function refuse(code: McpStdioErrorCode, reason: string): McpStdioResult {
|
|
132
|
+
return { ok: false, error: { code, message: `@softure-ai/mcp-access: ${reason}` } };
|
|
133
|
+
}
|