@softure-ai/mcp-access 0.2.4 → 0.2.6
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 +25 -0
- package/README.md +50 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +3 -2
- package/dist/next/index.js.map +1 -1
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +5 -56
- package/dist/next/pages.js.map +1 -1
- package/dist/next/token-manager-props.d.ts +18 -0
- package/dist/next/token-manager-props.d.ts.map +1 -0
- package/dist/next/token-manager-props.js +74 -0
- package/dist/next/token-manager-props.js.map +1 -0
- package/dist/server/action-tool.d.ts +6 -2
- package/dist/server/action-tool.d.ts.map +1 -1
- package/dist/server/action-tool.js +10 -3
- package/dist/server/action-tool.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/tokens.d.ts +5 -0
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +10 -0
- package/dist/server/tokens.js.map +1 -1
- package/dist/server/tools.d.ts +40 -1
- package/dist/server/tools.d.ts.map +1 -1
- package/dist/server/tools.js +41 -3
- package/dist/server/tools.js.map +1 -1
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +18 -3
- package/dist/ui/token-manager.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/next/index.ts +3 -2
- package/src/next/pages.tsx +7 -68
- package/src/next/token-manager-props.ts +92 -0
- package/src/server/action-tool.ts +15 -4
- package/src/server/index.ts +12 -1
- package/src/server/tokens.ts +10 -0
- package/src/server/tools.ts +78 -5
- package/src/ui/token-manager.tsx +21 -6
package/src/server/tools.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Tool results for the app's MCP server and the boundary that keeps failures safe: the assistant gets
|
|
2
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`).
|
|
3
|
+
// its SQL and parameters). The log gets the error's kind only (`errorLogLabel`). An optional hook sees
|
|
4
|
+
// each call as a whole: which tool, which client, the outcome with its code, and how long it took.
|
|
4
5
|
import { isCallToolResult, type CallToolResult } from "@modelcontextprotocol/server";
|
|
5
|
-
import { errorLogLabel, getPublicMessage, safeError } from "@softure-ai/core";
|
|
6
|
+
import { errorLogLabel, getPublicMessage, safeError, type CoreErrorCode } from "@softure-ai/core";
|
|
6
7
|
|
|
7
8
|
/** 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
9
|
export type ToolErrorHints = Readonly<Record<string, string>>;
|
|
@@ -10,10 +11,46 @@ export type ToolErrorHints = Readonly<Record<string, string>>;
|
|
|
10
11
|
export interface ToolErrorOptions {
|
|
11
12
|
/** Texts per error code; a code without one is answered as the code itself. */
|
|
12
13
|
readonly hints?: ToolErrorHints;
|
|
13
|
-
/** The tool's name in the log line. */
|
|
14
|
+
/** The tool's name in the log line and in the `onToolCall` record. */
|
|
14
15
|
readonly label?: string;
|
|
16
|
+
/** Called once after each call with its record. Its failure is logged by kind and never changes the answer. */
|
|
17
|
+
readonly onToolCall?: ToolCallHook;
|
|
18
|
+
/** Who called, for the record: built once per request in the app's server factory. */
|
|
19
|
+
readonly caller?: ToolCaller;
|
|
20
|
+
/** The tool's arguments, for the record: only their names are read, never their values. */
|
|
21
|
+
readonly args?: Readonly<Record<string, unknown>>;
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
/** The client behind a call: the token's id (`McpServerIdentity.tokenId`) and its name (`findTokenClientName`). */
|
|
25
|
+
export interface ToolCaller {
|
|
26
|
+
readonly tokenId: string;
|
|
27
|
+
readonly clientName: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How a call ended. `succeeded`: `itemCount` is the length of an array value, else `null`. `failed`: the
|
|
32
|
+
* result's error code, `null` for a `CallToolResult` the tool marked `isError` itself. `thrown`: the
|
|
33
|
+
* `safeError` code, `null` for a `PublicError` (a deliberate message, no code).
|
|
34
|
+
*/
|
|
35
|
+
export type ToolCallOutcome =
|
|
36
|
+
| { readonly kind: "succeeded"; readonly itemCount: number | null }
|
|
37
|
+
| { readonly kind: "failed"; readonly code: string | null }
|
|
38
|
+
| { readonly kind: "thrown"; readonly code: CoreErrorCode | null };
|
|
39
|
+
|
|
40
|
+
/** One tool call as the `onToolCall` hook sees it. */
|
|
41
|
+
export interface ToolCallRecord {
|
|
42
|
+
/** The tool's `label`, else `"call"`. */
|
|
43
|
+
readonly tool: string;
|
|
44
|
+
readonly tokenId: string | null;
|
|
45
|
+
readonly clientName: string | null;
|
|
46
|
+
readonly argumentNames: readonly string[];
|
|
47
|
+
readonly outcome: ToolCallOutcome;
|
|
48
|
+
readonly startedAt: Date;
|
|
49
|
+
readonly durationMs: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ToolCallHook = (record: ToolCallRecord) => unknown;
|
|
53
|
+
|
|
17
54
|
/** For failures the tool did not expect: what `safeError` makes of them, unless a hint replaces it. */
|
|
18
55
|
const DEFAULT_FAILURE_TEXT: ToolErrorHints = {
|
|
19
56
|
"core.database_failed": "The database could not answer; try again later.",
|
|
@@ -37,11 +74,47 @@ export function toolError(message: string): CallToolResult {
|
|
|
37
74
|
* anything else is logged by kind and answered with the hint for its `safeError` code, else a fixed text.
|
|
38
75
|
*/
|
|
39
76
|
export async function withToolErrors(work: () => unknown, options: ToolErrorOptions = {}): Promise<CallToolResult> {
|
|
77
|
+
const startedAt = Date.now();
|
|
78
|
+
let answer: CallToolResult;
|
|
79
|
+
let outcome: ToolCallOutcome;
|
|
40
80
|
try {
|
|
41
|
-
|
|
81
|
+
const value = await work();
|
|
82
|
+
answer = answerValue(value, options.hints);
|
|
83
|
+
outcome = describeValueOutcome(value);
|
|
42
84
|
} catch (error) {
|
|
43
|
-
|
|
85
|
+
answer = answerFailure(error, options);
|
|
86
|
+
outcome = { kind: "thrown", code: getPublicMessage(error) === null ? safeError(error).error : null };
|
|
44
87
|
}
|
|
88
|
+
if (options.onToolCall !== undefined) await reportToolCall(options, outcome, startedAt);
|
|
89
|
+
return answer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function reportToolCall(options: ToolErrorOptions, outcome: ToolCallOutcome, startedAt: number): Promise<void> {
|
|
93
|
+
const tool = options.label ?? "call";
|
|
94
|
+
const record: ToolCallRecord = {
|
|
95
|
+
tool,
|
|
96
|
+
tokenId: options.caller?.tokenId ?? null,
|
|
97
|
+
clientName: options.caller?.clientName ?? null,
|
|
98
|
+
argumentNames: Object.keys(options.args ?? {}),
|
|
99
|
+
outcome,
|
|
100
|
+
startedAt: new Date(startedAt),
|
|
101
|
+
durationMs: Date.now() - startedAt,
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
await options.onToolCall?.(record);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(`@softure-ai/mcp-access: the onToolCall hook failed for tool ${tool}: ${errorLogLabel(error)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describeValueOutcome(value: unknown): ToolCallOutcome {
|
|
111
|
+
if (isCallToolResult(value)) return value.isError === true ? { kind: "failed", code: null } : { kind: "succeeded", itemCount: null };
|
|
112
|
+
if (isResult(value)) return value.ok ? countItems(value.value) : { kind: "failed", code: value.error };
|
|
113
|
+
return countItems(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function countItems(value: unknown): ToolCallOutcome {
|
|
117
|
+
return { kind: "succeeded", itemCount: Array.isArray(value) ? value.length : null };
|
|
45
118
|
}
|
|
46
119
|
|
|
47
120
|
/** The answer for a value a tool or an action returned. */
|
package/src/ui/token-manager.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { formatMessage, type Locale } from "@softure-ai/core";
|
|
4
4
|
import { Button, ButtonLink, Checkbox, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
|
|
5
|
-
import { useActionState, useState } from "react";
|
|
5
|
+
import { useActionState, useEffect, useRef, useState } from "react";
|
|
6
6
|
import type { IssuedToken, IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
|
|
7
7
|
import { getTokenErrorMessage, type McpAccessMessages } from "../messages/index.js";
|
|
8
8
|
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
@@ -11,8 +11,9 @@ import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
|
11
11
|
// setup snippets, the owner's tokens with a revoke button each, and, with OAuth on, the apps
|
|
12
12
|
// connected through OAuth with a disconnect button each. Both forms submit to their
|
|
13
13
|
// server actions through `useActionState`. The plaintext lives only in the issue form's state:
|
|
14
|
-
// "Done" or leaving the page drops it.
|
|
15
|
-
//
|
|
14
|
+
// "Done" or leaving the page drops it. Focus follows the one-time token: its heading takes focus
|
|
15
|
+
// when it appears, and "Done" hands focus back to the name field. Copy comes from the module's
|
|
16
|
+
// messages; styling only from @softure-ai/ui classes.
|
|
16
17
|
|
|
17
18
|
export type IssueTokenAction = (previous: IssueTokenFormState, formData: FormData) => Promise<IssueTokenFormState>;
|
|
18
19
|
export type RevokeTokenAction = (previous: RevokeTokenFormState, formData: FormData) => Promise<RevokeTokenFormState>;
|
|
@@ -124,9 +125,14 @@ function IssuedTokenPanel({
|
|
|
124
125
|
unstyled: boolean | undefined;
|
|
125
126
|
}) {
|
|
126
127
|
const snippet = (title: string, value: string) => <Snippet title={title} value={value} messages={messages} slot={slot} unstyled={unstyled} />;
|
|
128
|
+
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
129
|
+
// Once per issued token, so a screen reader announces it; not on re-renders while the owner copies.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
headingRef.current?.focus();
|
|
132
|
+
}, [issued.id]);
|
|
127
133
|
return (
|
|
128
134
|
<section className={slot("section")}>
|
|
129
|
-
<h3 className={slot("heading")}>{formatMessage(messages.issued.title, { name: issued.name })}</h3>
|
|
135
|
+
<h3 ref={headingRef} tabIndex={-1} className={slot("heading")}>{formatMessage(messages.issued.title, { name: issued.name })}</h3>
|
|
130
136
|
<p className={slot("text")}>{messages.issued.once}</p>
|
|
131
137
|
<p className={slot("text")}>
|
|
132
138
|
{issued.expiresText} {issued.canWrite ? messages.issued.readWrite : messages.issued.readOnly}
|
|
@@ -168,11 +174,20 @@ function IssueForm({
|
|
|
168
174
|
// The id of the issued token the owner dismissed; a new one shows again.
|
|
169
175
|
const [dismissedId, setDismissedId] = useState<string | null>(null);
|
|
170
176
|
const issued = state.status === "ok" && state.issued.id !== dismissedId ? state.issued : null;
|
|
177
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
178
|
+
function dismiss(id: string) {
|
|
179
|
+
setDismissedId(id);
|
|
180
|
+
// The focused "Done" button unmounts with the panel; once it is gone, the name field is the next step.
|
|
181
|
+
requestAnimationFrame(() => {
|
|
182
|
+
const field = formRef.current?.elements.namedItem("name");
|
|
183
|
+
if (field instanceof HTMLInputElement) field.focus();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
171
186
|
return (
|
|
172
187
|
<>
|
|
173
188
|
<section className={slot("section")}>
|
|
174
189
|
<h2 className={slot("heading")}>{messages.issue.title}</h2>
|
|
175
|
-
<form action={formAction} className={slot("list")}>
|
|
190
|
+
<form ref={formRef} action={formAction} className={slot("list")}>
|
|
176
191
|
<TextField
|
|
177
192
|
name="name"
|
|
178
193
|
label={messages.issue.name}
|
|
@@ -191,7 +206,7 @@ function IssueForm({
|
|
|
191
206
|
</Button>
|
|
192
207
|
</form>
|
|
193
208
|
</section>
|
|
194
|
-
{issued === null ? null : <IssuedTokenPanel issued={issued} onDone={() =>
|
|
209
|
+
{issued === null ? null : <IssuedTokenPanel issued={issued} onDone={() => dismiss(issued.id)} messages={messages} slot={slot} unstyled={unstyled} />}
|
|
195
210
|
</>
|
|
196
211
|
);
|
|
197
212
|
}
|