@softure-ai/mcp-access 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +126 -4
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +30 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +24 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +58 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +4 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +20 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -11
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +4 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +2 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +2 -2
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +8 -47
- package/dist/next/actions.js.map +1 -1
- package/dist/next/format.d.ts +1 -1
- package/dist/next/format.d.ts.map +1 -1
- package/dist/next/format.js +3 -1
- package/dist/next/format.js.map +1 -1
- package/dist/next/index.d.ts +2 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/issue-token.d.ts +15 -0
- package/dist/next/issue-token.d.ts.map +1 -0
- package/dist/next/issue-token.js +77 -0
- package/dist/next/issue-token.js.map +1 -0
- package/dist/next/pages.d.ts +9 -1
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +2 -2
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +16 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -1
- package/dist/routes.d.ts +15 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +17 -0
- package/dist/routes.js.map +1 -0
- package/dist/server/action-tool.d.ts +21 -0
- package/dist/server/action-tool.d.ts.map +1 -0
- package/dist/server/action-tool.js +51 -0
- package/dist/server/action-tool.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/prune.d.ts +8 -0
- package/dist/server/prune.d.ts.map +1 -0
- package/dist/server/prune.js +10 -0
- package/dist/server/prune.js.map +1 -0
- package/dist/server/tools.d.ts +25 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +61 -0
- package/dist/server/tools.js.map +1 -0
- package/dist/stdio/index.d.ts +2 -0
- package/dist/stdio/index.d.ts.map +1 -0
- package/dist/stdio/index.js +4 -0
- package/dist/stdio/index.js.map +1 -0
- package/dist/stdio/register.d.ts +2 -0
- package/dist/stdio/register.d.ts.map +1 -0
- package/dist/stdio/register.js +10 -0
- package/dist/stdio/register.js.map +1 -0
- package/dist/stdio/serve.d.ts +45 -0
- package/dist/stdio/serve.d.ts.map +1 -0
- package/dist/stdio/serve.js +89 -0
- package/dist/stdio/serve.js.map +1 -0
- package/dist/testing/catalog.d.ts +15 -0
- package/dist/testing/catalog.d.ts.map +1 -0
- package/dist/testing/catalog.js +51 -0
- package/dist/testing/catalog.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/module.json +1 -1
- package/package.json +31 -5
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +38 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +79 -0
- package/src/contract.ts +5 -2
- package/src/index.ts +9 -12
- package/src/messages/en.ts +2 -0
- package/src/messages/pl.ts +2 -0
- package/src/next/actions.ts +8 -45
- package/src/next/format.ts +2 -2
- package/src/next/index.ts +2 -1
- package/src/next/issue-token.ts +90 -0
- package/src/next/pages.tsx +11 -3
- package/src/options.ts +4 -0
- package/src/routes.ts +17 -0
- package/src/server/action-tool.ts +64 -0
- package/src/server/index.ts +3 -0
- package/src/server/prune.ts +14 -0
- package/src/server/tools.ts +72 -0
- package/src/stdio/index.ts +12 -0
- package/src/stdio/register.ts +11 -0
- package/src/stdio/serve.ts +133 -0
- package/src/testing/catalog.ts +74 -0
- package/src/testing/index.ts +2 -0
|
@@ -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
|
+
}
|