@softure-ai/mcp-access 0.1.12 → 0.1.14
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 +34 -0
- package/README.md +141 -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 +19 -4
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +21 -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 +22 -0
- package/dist/next/issue-token.d.ts.map +1 -0
- package/dist/next/issue-token.js +93 -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 +17 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +13 -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/dist/ui/token-manager.js +1 -1
- package/dist/ui/token-manager.js.map +1 -1
- 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 +19 -4
- package/src/index.ts +10 -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 +113 -0
- package/src/next/pages.tsx +11 -3
- package/src/options.ts +13 -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/ui/token-manager.tsx +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
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. The gate refuses with a code, a code and its own text, or a thrown
|
|
12
|
+
// `PublicError`; any other failure, the gate's included, becomes a `safeError` code.
|
|
13
|
+
import { getCurrentUser } from "@softure-ai/auth/next";
|
|
14
|
+
import { errorLogLabel, formatMessage, getPublicMessage, safeError, type Err, type Ok } from "@softure-ai/core";
|
|
15
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
16
|
+
import { revalidatePath } from "next/cache";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { getMcpClientSetup } from "../client-setup.js";
|
|
19
|
+
import type { IssueTokenFormState, McpIssueRefusal, McpIssueRefusalCode } from "../contract.js";
|
|
20
|
+
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
21
|
+
import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
|
|
22
|
+
import { issueAccessToken } from "../server/tokens.js";
|
|
23
|
+
import { getMcpAccessContext, getRequestOrigins } from "./context.js";
|
|
24
|
+
import { formatDate } from "./format.js";
|
|
25
|
+
|
|
26
|
+
/** Longer values are cut: the server functions refuse them anyway, and nothing huge is echoed back. */
|
|
27
|
+
const MAX_FIELD_LENGTH = MAX_TOKEN_NAME_LENGTH * 4;
|
|
28
|
+
|
|
29
|
+
const issueInput = z.object({
|
|
30
|
+
name: z
|
|
31
|
+
.string()
|
|
32
|
+
.catch("")
|
|
33
|
+
.transform((value) => value.slice(0, MAX_FIELD_LENGTH)),
|
|
34
|
+
// A checked HTML checkbox sends "on"; an unchecked one sends nothing, which means read only.
|
|
35
|
+
canWrite: z.string().nullable().catch(null),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** What the gate learns about the token about to be issued. */
|
|
39
|
+
export interface IssueGateInput {
|
|
40
|
+
readonly userId: string;
|
|
41
|
+
readonly canWrite: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How the gate answers: `ok()` lets the token through, `err(code)` refuses with the code's copy,
|
|
46
|
+
* `refuseIssue(code, message)` with the app's own text.
|
|
47
|
+
*/
|
|
48
|
+
export type IssueGateResult = Ok<unknown> | Err<McpIssueRefusalCode> | McpIssueRefusal;
|
|
49
|
+
|
|
50
|
+
/** The app's check before a token is issued. Throwing a `PublicError` refuses too, with its message. */
|
|
51
|
+
export type BeforeIssueGate = (input: IssueGateInput) => IssueGateResult | Promise<IssueGateResult>;
|
|
52
|
+
|
|
53
|
+
export interface IssueTokenOptions {
|
|
54
|
+
readonly beforeIssue?: BeforeIssueGate;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Issues a token for the signed-in user, after the gate, and returns its plaintext with the setup snippets, once. */
|
|
58
|
+
export async function issueToken(_previous: IssueTokenFormState, formData: FormData, options: IssueTokenOptions = {}): Promise<IssueTokenFormState> {
|
|
59
|
+
const config = getSoftureConfig();
|
|
60
|
+
const user = await getCurrentUser();
|
|
61
|
+
if (user === null) return { status: "error", error: "auth.unauthenticated" };
|
|
62
|
+
|
|
63
|
+
// Every field has a `catch`, so parsing cannot fail.
|
|
64
|
+
const input = issueInput.parse({ name: formData.get("name"), canWrite: formData.get("canWrite") });
|
|
65
|
+
const canWrite = input.canWrite !== null;
|
|
66
|
+
try {
|
|
67
|
+
if (options.beforeIssue !== undefined) {
|
|
68
|
+
const gate = await options.beforeIssue({ userId: user.id, canWrite });
|
|
69
|
+
if (!gate.ok) return getRefusalState(gate.error, "message" in gate ? gate.message : undefined);
|
|
70
|
+
}
|
|
71
|
+
// Resolved before issuing: a failure afterwards would leave a token nobody received.
|
|
72
|
+
const endpointUrl = getMcpEndpointUrl(config, await getRequestOrigins(config, getMcpAccessRoutes(config).page));
|
|
73
|
+
const result = await issueAccessToken(await getMcpAccessContext(config), { userId: user.id, name: input.name, canWrite });
|
|
74
|
+
if (!result.ok) return { status: "error", error: result.error };
|
|
75
|
+
revalidatePath(getMcpAccessRoutes(config).page);
|
|
76
|
+
const issued = result.value;
|
|
77
|
+
const messages = getMcpAccessMessages(config);
|
|
78
|
+
const accessOptions = getMcpAccessOptions(config);
|
|
79
|
+
return {
|
|
80
|
+
status: "ok",
|
|
81
|
+
issued: {
|
|
82
|
+
id: issued.id,
|
|
83
|
+
name: issued.name,
|
|
84
|
+
canWrite: issued.canWrite,
|
|
85
|
+
expiresText: formatMessage(messages.issued.expires, { date: formatDate(config, issued.expiresAt) }),
|
|
86
|
+
expiresAt: issued.expiresAt.toISOString(),
|
|
87
|
+
setup: getMcpClientSetup({
|
|
88
|
+
serverName: accessOptions.clientServerName ?? accessOptions.serverName,
|
|
89
|
+
endpointUrl,
|
|
90
|
+
token: issued.token,
|
|
91
|
+
promptTemplate: messages.setup.assistantPrompt,
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// A deliberate refusal written for the user, not a failure: shown, not logged.
|
|
97
|
+
const publicMessage = getPublicMessage(error);
|
|
98
|
+
if (publicMessage !== null) return getRefusalState("mcp-access.issue_refused", publicMessage);
|
|
99
|
+
console.error(`@softure-ai/mcp-access: issuing a token failed: ${errorLogLabel(error)}`);
|
|
100
|
+
return { status: "error", error: safeError(error).error };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A gate's refusal with the app's own text, which the token page shows instead of the code's copy. */
|
|
105
|
+
export function refuseIssue(code: McpIssueRefusalCode, message: string): McpIssueRefusal {
|
|
106
|
+
return { ok: false, error: code, message };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The form state for a gate's refusal; a missing or blank text falls back to the code's copy. */
|
|
110
|
+
function getRefusalState(code: McpIssueRefusalCode, text: unknown): IssueTokenFormState {
|
|
111
|
+
const message = typeof text === "string" ? text.trim() : "";
|
|
112
|
+
return message === "" ? { status: "error", error: code } : { status: "error", error: code, message };
|
|
113
|
+
}
|
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
|
/**
|
|
@@ -121,6 +125,15 @@ export const mcpAccessOptionsSchema = z
|
|
|
121
125
|
.strictObject({
|
|
122
126
|
/** The name MCP clients list the server under, e.g. "acme". Used in the setup instructions. */
|
|
123
127
|
serverName: z.string().regex(SERVER_NAME_PATTERN, "must be lowercase letters, digits and inner dashes, at most 64 characters"),
|
|
128
|
+
/**
|
|
129
|
+
* The name the setup snippets register the server under in an MCP client (`claude mcp add`, the
|
|
130
|
+
* JSON configs, the assistant prompt), when it differs from `serverName`: an app keeps the entry its
|
|
131
|
+
* users already added. Unset, `serverName`.
|
|
132
|
+
*/
|
|
133
|
+
clientServerName: z
|
|
134
|
+
.string()
|
|
135
|
+
.regex(SERVER_NAME_PATTERN, "must be lowercase letters, digits and inner dashes, at most 64 characters")
|
|
136
|
+
.optional(),
|
|
124
137
|
/** The app's tool catalog, shown on the token page. The MCP server itself comes from the route. */
|
|
125
138
|
tools: z.array(toolSchema).default([]),
|
|
126
139
|
/** Lets write tokens be issued and used. Off, every token reads only, write tokens included. */
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Catalog parity: the tools listed in softure.config.ts (the token page shows them, agent docs read
|
|
2
|
+
// them) against the tools the app's MCP server really registers, for a read-only and a write identity.
|
|
3
|
+
// Framework-free: `expectToolCatalogMatchesServer` throws, so it works in any test runner.
|
|
4
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
5
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
6
|
+
import type { McpServerIdentity } from "../contract.js";
|
|
7
|
+
import type { McpServerFactory } from "../server/endpoint.js";
|
|
8
|
+
import { getMcpAccessOptions } from "../server/options.js";
|
|
9
|
+
|
|
10
|
+
export interface ToolCatalogDifferences {
|
|
11
|
+
/** Configured tools the server does not register for the identity that should get them. */
|
|
12
|
+
readonly missing: readonly string[];
|
|
13
|
+
/** Tools the server registers that the catalog does not list. */
|
|
14
|
+
readonly unlisted: readonly string[];
|
|
15
|
+
/** Configured write tools the server gives a read-only identity. */
|
|
16
|
+
readonly writeForReaders: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const READER: McpServerIdentity = { userId: "00000000-0000-4000-8000-000000000001", canWrite: false, tokenId: "catalog-check" };
|
|
20
|
+
const WRITER: McpServerIdentity = { ...READER, canWrite: true };
|
|
21
|
+
|
|
22
|
+
/** Every difference between the configured catalog and the server, each list in the catalog's order, then the server's. */
|
|
23
|
+
export async function getToolCatalogDifferences(config: SoftureConfig, createServer: McpServerFactory): Promise<ToolCatalogDifferences> {
|
|
24
|
+
const catalog = getMcpAccessOptions(config).tools;
|
|
25
|
+
const forReaders = await listServerTools(createServer, READER);
|
|
26
|
+
const forWriters = await listServerTools(createServer, WRITER);
|
|
27
|
+
const listed = new Set(catalog.map((tool) => tool.name));
|
|
28
|
+
return {
|
|
29
|
+
missing: catalog.filter((tool) => !(tool.access === "read" ? forReaders.includes(tool.name) && forWriters.includes(tool.name) : forWriters.includes(tool.name))).map((tool) => tool.name),
|
|
30
|
+
unlisted: [...new Set([...forReaders, ...forWriters])].filter((name) => !listed.has(name)),
|
|
31
|
+
writeForReaders: catalog.filter((tool) => tool.access === "write" && forReaders.includes(tool.name)).map((tool) => tool.name),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Throws a list of every difference, e.g. in a unit test next to the server. */
|
|
36
|
+
export async function expectToolCatalogMatchesServer(config: SoftureConfig, createServer: McpServerFactory): Promise<void> {
|
|
37
|
+
const differences = await getToolCatalogDifferences(config, createServer);
|
|
38
|
+
const problems = [
|
|
39
|
+
differences.missing.length > 0 ? `missing from the server: ${differences.missing.join(", ")}` : null,
|
|
40
|
+
differences.unlisted.length > 0 ? `registered but not in the catalog: ${differences.unlisted.join(", ")}` : null,
|
|
41
|
+
differences.writeForReaders.length > 0 ? `write tools a read-only token gets: ${differences.writeForReaders.join(", ")}` : null,
|
|
42
|
+
].filter((problem) => problem !== null);
|
|
43
|
+
if (problems.length > 0) {
|
|
44
|
+
throw new Error(`@softure-ai/mcp-access: the tool catalog in softure.config.ts does not match the MCP server: ${problems.join("; ")}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The tool names a fresh server lists for the identity, asked the way the endpoint serves it. */
|
|
49
|
+
async function listServerTools(createServer: McpServerFactory, identity: McpServerIdentity): Promise<string[]> {
|
|
50
|
+
const handler = createMcpHandler(() => createServer(identity), { responseMode: "json" });
|
|
51
|
+
const response = await handler.fetch(
|
|
52
|
+
new Request("http://localhost/api/mcp", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
55
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
const answer = readAnswer(await response.text());
|
|
59
|
+
if (answer.result?.tools === undefined) {
|
|
60
|
+
throw new Error(`@softure-ai/mcp-access: listing the server's tools failed: ${typeof answer.error?.message === "string" ? answer.error.message : `HTTP ${String(response.status)}`}`);
|
|
61
|
+
}
|
|
62
|
+
return answer.result.tools.map((tool) => (typeof tool.name === "string" ? tool.name : "")).filter((name) => name !== "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ToolsListAnswer {
|
|
66
|
+
readonly result?: { readonly tools?: readonly { readonly name?: unknown }[] };
|
|
67
|
+
readonly error?: { readonly message?: unknown };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The JSON-RPC answer, sent as JSON or as one SSE `message` event. */
|
|
71
|
+
function readAnswer(body: string): ToolsListAnswer {
|
|
72
|
+
const data = body.startsWith("event:") ? (body.split("\n").find((line) => line.startsWith("data: "))?.slice("data: ".length) ?? "null") : body;
|
|
73
|
+
return (JSON.parse(data) ?? {}) as ToolsListAnswer;
|
|
74
|
+
}
|
package/src/ui/token-manager.tsx
CHANGED
|
@@ -185,7 +185,7 @@ function IssueForm({
|
|
|
185
185
|
/>
|
|
186
186
|
{allowWrites ? <Checkbox name="canWrite" label={messages.issue.canWrite} description={messages.issue.canWriteHint} unstyled={unstyled} /> : null}
|
|
187
187
|
<p className={slot("text")}>{formatMessage(messages.issue.limit, { max: maxTokens })}</p>
|
|
188
|
-
<FormError message={state.status === "error" ? getTokenErrorMessage(messages, state.error) : undefined} unstyled={unstyled} />
|
|
188
|
+
<FormError message={state.status === "error" ? (state.message ?? getTokenErrorMessage(messages, state.error)) : undefined} unstyled={unstyled} />
|
|
189
189
|
<Button type="submit" variant="primary" pending={isPending} unstyled={unstyled}>
|
|
190
190
|
{isPending ? messages.issue.pending : messages.issue.submit}
|
|
191
191
|
</Button>
|