@vvedantb/server 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/convex/authConfig.d.ts +18 -0
- package/dist/convex/authConfig.js +19 -0
- package/dist/convex/bearerToken.d.ts +2 -0
- package/dist/convex/bearerToken.js +11 -0
- package/dist/convex/clerk.d.ts +13 -0
- package/dist/convex/clerk.js +17 -0
- package/dist/convex/convexQueryLimits.d.ts +11 -0
- package/dist/convex/convexQueryLimits.js +28 -0
- package/dist/convex/cors.d.ts +11 -0
- package/dist/convex/cors.js +20 -0
- package/dist/convex/env.d.ts +5 -0
- package/dist/convex/env.js +12 -0
- package/dist/convex/index.d.ts +8 -0
- package/dist/convex/index.js +8 -0
- package/dist/convex/mcpServerInfo.d.ts +6 -0
- package/dist/convex/mcpServerInfo.js +8 -0
- package/dist/convex/mcpTestHelpers.d.ts +7 -0
- package/dist/convex/mcpTestHelpers.js +22 -0
- package/dist/mcp/cors.d.ts +14 -0
- package/dist/mcp/cors.js +13 -0
- package/dist/mcp/dispatcher.d.ts +39 -0
- package/dist/mcp/dispatcher.js +105 -0
- package/dist/mcp/env.d.ts +23 -0
- package/dist/mcp/env.js +20 -0
- package/dist/mcp/explorer.d.ts +98 -0
- package/dist/mcp/explorer.js +299 -0
- package/dist/mcp/http.d.ts +105 -0
- package/dist/mcp/http.js +123 -0
- package/dist/mcp/index.d.ts +10 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/json.d.ts +18 -0
- package/dist/mcp/json.js +7 -0
- package/dist/mcp/jsonRpc.d.ts +61 -0
- package/dist/mcp/jsonRpc.js +60 -0
- package/dist/mcp/metadata.d.ts +59 -0
- package/dist/mcp/metadata.js +63 -0
- package/dist/mcp/sdk.d.ts +56 -0
- package/dist/mcp/sdk.js +52 -0
- package/dist/mcp/tools.d.ts +176 -0
- package/dist/mcp/tools.js +159 -0
- package/docs/convex.md +69 -0
- package/docs/mcp.md +227 -0
- package/docs/migration.md +163 -0
- package/package.json +55 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vvedantb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vvedantb/server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-side helpers for Convex backends. One package, two subpaths.
|
|
4
|
+
|
|
5
|
+
| Import | What it holds | Docs |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `@vvedantb/server/convex` | Query-limit errors, Clerk auth config, bearer tokens, env, MCP CORS and server info. | [docs/convex.md](docs/convex.md) |
|
|
8
|
+
| `@vvedantb/server/mcp` | MCP server scaffolding for Convex `httpAction`s: bearer-auth handler, OAuth metadata, a pure JSON-RPC dispatcher, tool helpers and explorer tools. | [docs/mcp.md](docs/mcp.md) |
|
|
9
|
+
|
|
10
|
+
There is no `.` export. Import from a subpath.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @vvedantb/server
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { extractBearerToken } from "@vvedantb/server/convex";
|
|
18
|
+
import { createMcpHttpHandler } from "@vvedantb/server/mcp";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Peers
|
|
22
|
+
|
|
23
|
+
Both are optional.
|
|
24
|
+
|
|
25
|
+
- `@clerk/backend` `>=1`: only for `getClerkClient`.
|
|
26
|
+
- `zod` `^3.25.0 || ^4.0.0`: only if you pass zod schemas to `defineTool`.
|
|
27
|
+
|
|
28
|
+
No React, no web dependencies. Compiled ESM with types in `dist`, built on pack.
|
|
29
|
+
|
|
30
|
+
## Layout
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
src/convex/ @vvedantb/server/convex
|
|
34
|
+
src/mcp/ @vvedantb/server/mcp (imports ../convex by relative path)
|
|
35
|
+
tests/convex/ vitest
|
|
36
|
+
tests/mcp/ vitest, with fixtures/ (SDK captures, app references)
|
|
37
|
+
docs/ convex.md, mcp.md, migration.md, migrated/<app>/
|
|
38
|
+
scripts/ capture-sdk-fixtures.mjs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Develop
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pnpm --filter @vvedantb/server build
|
|
45
|
+
pnpm --filter @vvedantb/server test
|
|
46
|
+
pnpm --filter @vvedantb/server typecheck
|
|
47
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ClerkAuthConfigEnv = {
|
|
2
|
+
CLERK_FRONTEND_API_URL?: string | undefined;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Convex `auth.config.ts` for Clerk. The domain comes from
|
|
6
|
+
* `CLERK_FRONTEND_API_URL`; the application ID is always `"convex"`.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { clerkAuthConfig } from "@vvedantb/server/convex";
|
|
10
|
+
* export default clerkAuthConfig();
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function clerkAuthConfig(env?: ClerkAuthConfigEnv): {
|
|
14
|
+
providers: Array<{
|
|
15
|
+
domain: string | undefined;
|
|
16
|
+
applicationID: string;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex `auth.config.ts` for Clerk. The domain comes from
|
|
3
|
+
* `CLERK_FRONTEND_API_URL`; the application ID is always `"convex"`.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { clerkAuthConfig } from "@vvedantb/server/convex";
|
|
7
|
+
* export default clerkAuthConfig();
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export function clerkAuthConfig(env = process.env) {
|
|
11
|
+
return {
|
|
12
|
+
providers: [
|
|
13
|
+
{
|
|
14
|
+
domain: env.CLERK_FRONTEND_API_URL,
|
|
15
|
+
applicationID: "convex",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Extract the token from an `Authorization: Bearer <token>` header value. */
|
|
2
|
+
export function extractBearerToken(authHeader) {
|
|
3
|
+
if (!authHeader)
|
|
4
|
+
return null;
|
|
5
|
+
const [scheme, token, ...rest] = authHeader.split(" ");
|
|
6
|
+
if (rest.length > 0 || !scheme || !token)
|
|
7
|
+
return null;
|
|
8
|
+
if (scheme.toLowerCase() !== "bearer")
|
|
9
|
+
return null;
|
|
10
|
+
return token;
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ClerkClientEnv = {
|
|
2
|
+
CLERK_SECRET_KEY?: string | undefined;
|
|
3
|
+
CLERK_PUBLISHABLE_KEY?: string | undefined;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Build a Clerk backend client from env. The factory is injected so this
|
|
7
|
+
* package does not import `@clerk/backend`; pass its `createClerkClient`.
|
|
8
|
+
* Throws when either key is missing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getClerkClient<T>(createClerkClient: (opts: {
|
|
11
|
+
secretKey: string;
|
|
12
|
+
publishableKey: string;
|
|
13
|
+
}) => T, env?: ClerkClientEnv): T;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Clerk backend client from env. The factory is injected so this
|
|
3
|
+
* package does not import `@clerk/backend`; pass its `createClerkClient`.
|
|
4
|
+
* Throws when either key is missing.
|
|
5
|
+
*/
|
|
6
|
+
export function getClerkClient(createClerkClient, env = process.env) {
|
|
7
|
+
const secretKey = env.CLERK_SECRET_KEY;
|
|
8
|
+
const publishableKey = env.CLERK_PUBLISHABLE_KEY;
|
|
9
|
+
if (!secretKey || !publishableKey) {
|
|
10
|
+
const missing = [
|
|
11
|
+
secretKey ? null : "CLERK_SECRET_KEY",
|
|
12
|
+
publishableKey ? null : "CLERK_PUBLISHABLE_KEY",
|
|
13
|
+
].filter((name) => name !== null);
|
|
14
|
+
throw new Error(`${missing.join(" and ")} is not set in Convex env`);
|
|
15
|
+
}
|
|
16
|
+
return createClerkClient({ secretKey, publishableKey });
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex query resource-limit detection for MCP test queries.
|
|
3
|
+
*
|
|
4
|
+
* `run_test_function` failures like "Too many bytes read in a single function
|
|
5
|
+
* execution (limit: 16777216 bytes)" used to throw out of `runTestQuery` as
|
|
6
|
+
* an uncaught Server Error. Callers map the formatted message to an MCP
|
|
7
|
+
* `isError` result instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isConvexResourceLimitError(message: string): boolean;
|
|
10
|
+
/** User-facing MCP error text for a failed test query. */
|
|
11
|
+
export declare function formatConvexQueryError(message: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex query resource-limit detection for MCP test queries.
|
|
3
|
+
*
|
|
4
|
+
* `run_test_function` failures like "Too many bytes read in a single function
|
|
5
|
+
* execution (limit: 16777216 bytes)" used to throw out of `runTestQuery` as
|
|
6
|
+
* an uncaught Server Error. Callers map the formatted message to an MCP
|
|
7
|
+
* `isError` result instead.
|
|
8
|
+
*/
|
|
9
|
+
const CONVEX_RESOURCE_LIMIT_HINT = "Use smaller limits, paginate, or query with a selective index range.";
|
|
10
|
+
const CONVEX_RESOURCE_LIMIT_MARKERS = [
|
|
11
|
+
"too many bytes read",
|
|
12
|
+
"too many documents read",
|
|
13
|
+
"too many bytes written",
|
|
14
|
+
"too many writes in a single function",
|
|
15
|
+
"function execution timed out",
|
|
16
|
+
"maximum execution duration",
|
|
17
|
+
"consider using smaller limits in your queries",
|
|
18
|
+
];
|
|
19
|
+
export function isConvexResourceLimitError(message) {
|
|
20
|
+
const lowered = message.toLowerCase();
|
|
21
|
+
return CONVEX_RESOURCE_LIMIT_MARKERS.some((marker) => lowered.includes(marker));
|
|
22
|
+
}
|
|
23
|
+
/** User-facing MCP error text for a failed test query. */
|
|
24
|
+
export function formatConvexQueryError(message) {
|
|
25
|
+
if (!isConvexResourceLimitError(message))
|
|
26
|
+
return message;
|
|
27
|
+
return `This query exceeded a Convex resource limit (bytes read, documents read, or execution time). ${CONVEX_RESOURCE_LIMIT_HINT} Details: ${message}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** CORS headers for stateless MCP HTTP handlers. */
|
|
2
|
+
export declare function mcpCorsHeaders(origin?: string): Record<string, string>;
|
|
3
|
+
/** Response for an `OPTIONS` preflight request: 204 with CORS headers. */
|
|
4
|
+
export declare function corsPreflight(origin?: string): Response;
|
|
5
|
+
export type JsonWithCorsInit = {
|
|
6
|
+
status?: number;
|
|
7
|
+
origin?: string;
|
|
8
|
+
extraHeaders?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
/** JSON response with CORS headers. `extraHeaders` win over the CORS defaults. */
|
|
11
|
+
export declare function jsonWithCors(body: unknown, init?: JsonWithCorsInit): Response;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** CORS headers for stateless MCP HTTP handlers. */
|
|
2
|
+
export function mcpCorsHeaders(origin = "*") {
|
|
3
|
+
return {
|
|
4
|
+
"Access-Control-Allow-Origin": origin,
|
|
5
|
+
"Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
|
|
6
|
+
"Access-Control-Allow-Headers": "Authorization,Content-Type",
|
|
7
|
+
"Access-Control-Allow-Credentials": "false",
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Response for an `OPTIONS` preflight request: 204 with CORS headers. */
|
|
11
|
+
export function corsPreflight(origin) {
|
|
12
|
+
return new Response(null, { status: 204, headers: mcpCorsHeaders(origin) });
|
|
13
|
+
}
|
|
14
|
+
/** JSON response with CORS headers. `extraHeaders` win over the CORS defaults. */
|
|
15
|
+
export function jsonWithCors(body, init = {}) {
|
|
16
|
+
return Response.json(body, {
|
|
17
|
+
status: init.status ?? 200,
|
|
18
|
+
headers: { ...mcpCorsHeaders(init.origin), ...init.extraHeaders },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type EnvSource = Record<string, string | undefined>;
|
|
2
|
+
/** Read a required env var. Throws when it is unset or empty. */
|
|
3
|
+
export declare function requiredEnv(name: string, env?: EnvSource): string;
|
|
4
|
+
/** Read a required env var used as a URL prefix, stripping one trailing slash. */
|
|
5
|
+
export declare function requiredEnvUrl(name: string, env?: EnvSource): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Read a required env var. Throws when it is unset or empty. */
|
|
2
|
+
export function requiredEnv(name, env = process.env) {
|
|
3
|
+
const value = env[name];
|
|
4
|
+
if (!value) {
|
|
5
|
+
throw new Error(`${name} is not set in Convex env`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
/** Read a required env var used as a URL prefix, stripping one trailing slash. */
|
|
10
|
+
export function requiredEnvUrl(name, env = process.env) {
|
|
11
|
+
return requiredEnv(name, env).replace(/\/$/, "");
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { clerkAuthConfig, type ClerkAuthConfigEnv } from "./authConfig.js";
|
|
2
|
+
export { extractBearerToken } from "./bearerToken.js";
|
|
3
|
+
export { getClerkClient, type ClerkClientEnv } from "./clerk.js";
|
|
4
|
+
export { corsPreflight, jsonWithCors, mcpCorsHeaders, type JsonWithCorsInit, } from "./cors.js";
|
|
5
|
+
export { formatConvexQueryError, isConvexResourceLimitError, } from "./convexQueryLimits.js";
|
|
6
|
+
export { requiredEnv, requiredEnvUrl, type EnvSource } from "./env.js";
|
|
7
|
+
export { defineMcpServerInfo, type McpServerInfo } from "./mcpServerInfo.js";
|
|
8
|
+
export { assertMcpTools, expectMcpToolCount } from "./mcpTestHelpers.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { clerkAuthConfig } from "./authConfig.js";
|
|
2
|
+
export { extractBearerToken } from "./bearerToken.js";
|
|
3
|
+
export { getClerkClient } from "./clerk.js";
|
|
4
|
+
export { corsPreflight, jsonWithCors, mcpCorsHeaders, } from "./cors.js";
|
|
5
|
+
export { formatConvexQueryError, isConvexResourceLimitError, } from "./convexQueryLimits.js";
|
|
6
|
+
export { requiredEnv, requiredEnvUrl } from "./env.js";
|
|
7
|
+
export { defineMcpServerInfo } from "./mcpServerInfo.js";
|
|
8
|
+
export { assertMcpTools, expectMcpToolCount } from "./mcpTestHelpers.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Build the MCP `serverInfo` object. The caller owns the name and version. */
|
|
2
|
+
export function defineMcpServerInfo(options) {
|
|
3
|
+
if (!options.name)
|
|
4
|
+
throw new Error("MCP server name must not be empty");
|
|
5
|
+
if (!options.version)
|
|
6
|
+
throw new Error("MCP server version must not be empty");
|
|
7
|
+
return { name: options.name, version: options.version };
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Throw unless exactly `expected` tools are listed. The error lists the names. */
|
|
2
|
+
export declare function expectMcpToolCount(toolNames: readonly string[], expected: number): void;
|
|
3
|
+
/**
|
|
4
|
+
* Throw unless the sorted, de-duplicated tool names equal the expected set.
|
|
5
|
+
* The error names the missing and unexpected tools.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertMcpTools(toolNames: readonly string[], expected: readonly string[]): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function format(names) {
|
|
2
|
+
return names.length === 0 ? "(none)" : names.join(", ");
|
|
3
|
+
}
|
|
4
|
+
/** Throw unless exactly `expected` tools are listed. The error lists the names. */
|
|
5
|
+
export function expectMcpToolCount(toolNames, expected) {
|
|
6
|
+
if (toolNames.length !== expected) {
|
|
7
|
+
throw new Error(`Expected ${expected} MCP tools but found ${toolNames.length}: ${format(toolNames)}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Throw unless the sorted, de-duplicated tool names equal the expected set.
|
|
12
|
+
* The error names the missing and unexpected tools.
|
|
13
|
+
*/
|
|
14
|
+
export function assertMcpTools(toolNames, expected) {
|
|
15
|
+
const actualSet = new Set(toolNames);
|
|
16
|
+
const expectedSet = new Set(expected);
|
|
17
|
+
const missing = [...expectedSet].filter((n) => !actualSet.has(n)).sort();
|
|
18
|
+
const unexpected = [...actualSet].filter((n) => !expectedSet.has(n)).sort();
|
|
19
|
+
if (missing.length === 0 && unexpected.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
throw new Error(`MCP tools differ. Missing: ${format(missing)}. Unexpected: ${format(unexpected)}. Actual: ${format([...actualSet].sort())}`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type HeaderMap = Record<string, string>;
|
|
2
|
+
/**
|
|
3
|
+
* CORS headers for every response.
|
|
4
|
+
* - `false` (default): no CORS headers, like app-a and app-c.
|
|
5
|
+
* - a header map: sent as-is, like app-b and app-d (see `mcpCors`).
|
|
6
|
+
* - a function: computed per request, e.g. to reflect `Origin` like app-e.
|
|
7
|
+
*/
|
|
8
|
+
export type CorsOption = false | HeaderMap | ((request: Request) => HeaderMap);
|
|
9
|
+
/**
|
|
10
|
+
* server/convex's `mcpCorsHeaders(origin)` with `overrides` applied on top.
|
|
11
|
+
* app-b: `mcpCors({ "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version", "Access-Control-Max-Age": "86400" })`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mcpCors(overrides?: HeaderMap, origin?: string): HeaderMap;
|
|
14
|
+
export declare function resolveCors(cors: CorsOption | undefined, request: Request): HeaderMap;
|
package/dist/mcp/cors.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mcpCorsHeaders } from "../convex/index";
|
|
2
|
+
/**
|
|
3
|
+
* server/convex's `mcpCorsHeaders(origin)` with `overrides` applied on top.
|
|
4
|
+
* app-b: `mcpCors({ "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version", "Access-Control-Max-Age": "86400" })`.
|
|
5
|
+
*/
|
|
6
|
+
export function mcpCors(overrides = {}, origin) {
|
|
7
|
+
return { ...mcpCorsHeaders(origin), ...overrides };
|
|
8
|
+
}
|
|
9
|
+
export function resolveCors(cors, request) {
|
|
10
|
+
if (!cors)
|
|
11
|
+
return {};
|
|
12
|
+
return typeof cors === "function" ? cors(request) : cors;
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { McpServerInfo } from "../convex/index";
|
|
2
|
+
import type { HandleRpcArgs } from "./http.js";
|
|
3
|
+
import { type JsonObject, type JsonValue } from "./json.js";
|
|
4
|
+
import { type ProtocolVersionOptions, type RpcResult } from "./jsonRpc.js";
|
|
5
|
+
import { type JsonSchemaConverter, type McpTool } from "./tools.js";
|
|
6
|
+
export type McpDispatcherConfig<Principal, Ctx, Scope extends string = string> = {
|
|
7
|
+
/** `serverInfo` in the initialize result (server/convex `defineMcpServerInfo`), or a function of the scope (app-a). */
|
|
8
|
+
serverInfo: McpServerInfo | ((scope: Scope | undefined) => McpServerInfo);
|
|
9
|
+
/** Tools. Names must be unique (checked at creation). */
|
|
10
|
+
tools: ReadonlyArray<McpTool<Principal, Ctx, Scope>>;
|
|
11
|
+
/** Supported protocol versions and fallback. Default: the SDK 1.29 to 1.32 list, fallback "2025-11-25". */
|
|
12
|
+
protocol?: ProtocolVersionOptions;
|
|
13
|
+
/** `capabilities` in the initialize result. Default `{ tools: { listChanged: true } }`, as `McpServer` sends. */
|
|
14
|
+
capabilities?: JsonObject;
|
|
15
|
+
/** `instructions` in the initialize result. Omitted by default. */
|
|
16
|
+
instructions?: string;
|
|
17
|
+
/** zod to JSON Schema converter for zod 3 schemas. */
|
|
18
|
+
toJsonSchema?: JsonSchemaConverter;
|
|
19
|
+
};
|
|
20
|
+
export type McpDispatcher<Principal, Ctx, Scope extends string = string> = {
|
|
21
|
+
/** Same contract as `createMcpHttpHandler`'s `handleRpc`. */
|
|
22
|
+
handleRpc(args: Pick<HandleRpcArgs<Principal, Ctx, Scope>, "principal" | "json" | "ctx" | "scope">): Promise<RpcResult>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A self-contained MCP JSON-RPC server for stateless HTTP: no SDK, no Node
|
|
26
|
+
* APIs. Handles initialize, ping, tools/list, tools/call, notifications
|
|
27
|
+
* (202, empty body) and batches. Other methods get -32601. Output follows
|
|
28
|
+
* MCP SDK 1.30+ with `enableJsonResponse: true`; see the README for the
|
|
29
|
+
* exact differences.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createMcpDispatcher<Principal, Ctx, Scope extends string = string>(config: McpDispatcherConfig<Principal, Ctx, Scope>): McpDispatcher<Principal, Ctx, Scope>;
|
|
32
|
+
/** Parse a JSON-RPC body string for `handleRpc`. Returns the SDK's 400 -32700 result on bad JSON. */
|
|
33
|
+
export declare function parseRpcBody(body: string): {
|
|
34
|
+
ok: true;
|
|
35
|
+
json: JsonValue;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
result: RpcResult;
|
|
39
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isJsonObject } from "./json.js";
|
|
2
|
+
import { JSON_RPC_ERROR, negotiateProtocolVersion, rpcError, rpcResult, } from "./jsonRpc.js";
|
|
3
|
+
import { createToolRegistry, toolErrorResult, } from "./tools.js";
|
|
4
|
+
function readMessage(value) {
|
|
5
|
+
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.method !== "string")
|
|
6
|
+
return null;
|
|
7
|
+
const id = value.id;
|
|
8
|
+
if (id !== undefined && id !== null && typeof id !== "string" && typeof id !== "number")
|
|
9
|
+
return null;
|
|
10
|
+
const params = value.params;
|
|
11
|
+
if (params !== undefined && !isJsonObject(params))
|
|
12
|
+
return null;
|
|
13
|
+
return { id, method: value.method, params: params ?? {} };
|
|
14
|
+
}
|
|
15
|
+
function stringOrUndefined(value) {
|
|
16
|
+
return typeof value === "string" ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A self-contained MCP JSON-RPC server for stateless HTTP: no SDK, no Node
|
|
20
|
+
* APIs. Handles initialize, ping, tools/list, tools/call, notifications
|
|
21
|
+
* (202, empty body) and batches. Other methods get -32601. Output follows
|
|
22
|
+
* MCP SDK 1.30+ with `enableJsonResponse: true`; see the README for the
|
|
23
|
+
* exact differences.
|
|
24
|
+
*/
|
|
25
|
+
export function createMcpDispatcher(config) {
|
|
26
|
+
const registry = createToolRegistry(config.tools);
|
|
27
|
+
const capabilities = config.capabilities ?? { tools: { listChanged: true } };
|
|
28
|
+
async function answer(message, context) {
|
|
29
|
+
const id = message.id ?? null;
|
|
30
|
+
switch (message.method) {
|
|
31
|
+
case "initialize": {
|
|
32
|
+
const serverInfo = typeof config.serverInfo === "function" ? config.serverInfo(context.scope) : config.serverInfo;
|
|
33
|
+
return rpcResult(id, {
|
|
34
|
+
protocolVersion: negotiateProtocolVersion(message.params.protocolVersion, config.protocol),
|
|
35
|
+
capabilities,
|
|
36
|
+
serverInfo,
|
|
37
|
+
...(config.instructions === undefined ? {} : { instructions: config.instructions }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
case "ping":
|
|
41
|
+
return rpcResult(id, {});
|
|
42
|
+
case "tools/list":
|
|
43
|
+
return rpcResult(id, {
|
|
44
|
+
tools: registry.list({ scope: context.scope, toJsonSchema: config.toJsonSchema }),
|
|
45
|
+
});
|
|
46
|
+
case "tools/call": {
|
|
47
|
+
const name = stringOrUndefined(message.params.name) ?? "";
|
|
48
|
+
const tool = registry.find(name, context.scope);
|
|
49
|
+
if (!tool) {
|
|
50
|
+
return rpcResult(id, toolErrorResult(`MCP error ${JSON_RPC_ERROR.invalidParams}: Tool ${name} not found`));
|
|
51
|
+
}
|
|
52
|
+
const args = message.params.arguments;
|
|
53
|
+
return rpcResult(id, await tool.call(isJsonObject(args) ? args : {}, context));
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return rpcError(id, JSON_RPC_ERROR.methodNotFound, "Method not found");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
async handleRpc({ principal, json, ctx, scope }) {
|
|
61
|
+
const context = { principal, ctx, scope };
|
|
62
|
+
const batch = Array.isArray(json);
|
|
63
|
+
const items = batch ? json : [json];
|
|
64
|
+
const messages = items.map(readMessage);
|
|
65
|
+
if (items.length === 0 || messages.some((message) => message === null)) {
|
|
66
|
+
return {
|
|
67
|
+
status: 400,
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
jsonrpc: "2.0",
|
|
70
|
+
error: { code: JSON_RPC_ERROR.invalidRequest, message: "Invalid Request" },
|
|
71
|
+
id: null,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const responses = [];
|
|
76
|
+
for (const message of messages) {
|
|
77
|
+
// Notifications (no id) get no response, as in JSON-RPC 2.0.
|
|
78
|
+
if (message && message.id !== undefined)
|
|
79
|
+
responses.push(await answer(message, context));
|
|
80
|
+
}
|
|
81
|
+
if (responses.length === 0)
|
|
82
|
+
return { status: 202, body: "" };
|
|
83
|
+
return { status: 200, body: JSON.stringify(batch ? responses : responses[0]) };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Parse a JSON-RPC body string for `handleRpc`. Returns the SDK's 400 -32700 result on bad JSON. */
|
|
88
|
+
export function parseRpcBody(body) {
|
|
89
|
+
try {
|
|
90
|
+
return { ok: true, json: JSON.parse(body) };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
result: {
|
|
96
|
+
status: 400,
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
error: { code: JSON_RPC_ERROR.parseError, message: "Parse error: Invalid JSON" },
|
|
100
|
+
id: null,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type EnvSource } from "../convex/index";
|
|
2
|
+
/**
|
|
3
|
+
* How a fallback env var is chosen. They differ only when the primary is
|
|
4
|
+
* unset or empty, and in which name the "is not set" error mentions.
|
|
5
|
+
*
|
|
6
|
+
* | mode | rule | used by |
|
|
7
|
+
* | --- | --- | --- |
|
|
8
|
+
* | `"fallback-if-set"` | fallback only when primary is empty and fallback is non-empty; else primary | app-d |
|
|
9
|
+
* | `"prefer-primary"` | primary when non-empty, else fallback (error names the fallback) | app-c |
|
|
10
|
+
* | `"nullish"` | `env[name] ?? env[fallback]`, read as `name` (an empty primary does not fall back) | app-b |
|
|
11
|
+
*/
|
|
12
|
+
export type EnvFallbackMode = "fallback-if-set" | "prefer-primary" | "nullish";
|
|
13
|
+
/** An env var holding a URL. A plain string is a name with no fallback. */
|
|
14
|
+
export type EnvUrl = string | {
|
|
15
|
+
name: string;
|
|
16
|
+
fallback?: string;
|
|
17
|
+
fallbackMode?: EnvFallbackMode;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Read a URL from env through server/convex's `requiredEnvUrl` (throws
|
|
21
|
+
* "<NAME> is not set in Convex env", strips one trailing slash).
|
|
22
|
+
*/
|
|
23
|
+
export declare function readEnvUrl(source: EnvUrl, env?: EnvSource): string;
|
package/dist/mcp/env.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { requiredEnvUrl } from "../convex/index";
|
|
2
|
+
/**
|
|
3
|
+
* Read a URL from env through server/convex's `requiredEnvUrl` (throws
|
|
4
|
+
* "<NAME> is not set in Convex env", strips one trailing slash).
|
|
5
|
+
*/
|
|
6
|
+
export function readEnvUrl(source, env = process.env) {
|
|
7
|
+
if (typeof source === "string")
|
|
8
|
+
return requiredEnvUrl(source, env);
|
|
9
|
+
const { name, fallback } = source;
|
|
10
|
+
if (!fallback)
|
|
11
|
+
return requiredEnvUrl(name, env);
|
|
12
|
+
switch (source.fallbackMode ?? "fallback-if-set") {
|
|
13
|
+
case "fallback-if-set":
|
|
14
|
+
return requiredEnvUrl(!env[name] && !!env[fallback] ? fallback : name, env);
|
|
15
|
+
case "prefer-primary":
|
|
16
|
+
return requiredEnvUrl(env[name] ? name : fallback, env);
|
|
17
|
+
case "nullish":
|
|
18
|
+
return requiredEnvUrl(name, { [name]: env[name] ?? env[fallback] });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type JsonInput } from "./json.js";
|
|
2
|
+
import { type McpTool, type ToolContext, type ToolResult } from "./tools.js";
|
|
3
|
+
/** A table the explorer tools may read. `fields`, when given, limits filter field names. */
|
|
4
|
+
export type TableEntry = {
|
|
5
|
+
name: string;
|
|
6
|
+
fields?: ReadonlyArray<string | {
|
|
7
|
+
name: string;
|
|
8
|
+
}>;
|
|
9
|
+
} & object;
|
|
10
|
+
/**
|
|
11
|
+
* Tables the tools accept: a fixed list (app-b, app-d), or a predicate for
|
|
12
|
+
* catalogs read at runtime (app-c: a deny-list). Names must also match
|
|
13
|
+
* `^[A-Za-z_][A-Za-z0-9_]*$`.
|
|
14
|
+
*/
|
|
15
|
+
export type TableCatalog = ReadonlyArray<string | TableEntry> | ((table: string) => boolean);
|
|
16
|
+
export type FilterValue = string | number | boolean | null;
|
|
17
|
+
/** Top-level field filters (app-d `eq` / `gte` / `lte`). */
|
|
18
|
+
export type ExplorerFilters = {
|
|
19
|
+
eq?: Record<string, FilterValue>;
|
|
20
|
+
gte?: Record<string, number>;
|
|
21
|
+
lte?: Record<string, number>;
|
|
22
|
+
};
|
|
23
|
+
/** A validated request for the app's executor. Table names are already checked against the catalog. */
|
|
24
|
+
export type ExplorerOperation = {
|
|
25
|
+
kind: "list_tables";
|
|
26
|
+
} | {
|
|
27
|
+
kind: "query_table";
|
|
28
|
+
table: string;
|
|
29
|
+
order: "asc" | "desc";
|
|
30
|
+
limit: number;
|
|
31
|
+
/** `null` unless `features.cursor` is on and the caller sent one. */
|
|
32
|
+
cursor: string | null;
|
|
33
|
+
/** `null` unless `features.filters` is on and the caller sent some. */
|
|
34
|
+
filters: ExplorerFilters | null;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "count_table";
|
|
37
|
+
table: string;
|
|
38
|
+
filters: ExplorerFilters | null;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "get_document";
|
|
41
|
+
id: string;
|
|
42
|
+
};
|
|
43
|
+
/** Validated `run_query` arguments. `tables` is set only with `features.runQueryTables`. */
|
|
44
|
+
export type RunQueryArgs = {
|
|
45
|
+
code: string;
|
|
46
|
+
tables?: string[];
|
|
47
|
+
limitPerTable?: number;
|
|
48
|
+
};
|
|
49
|
+
export type ExplorerToolKey = "listTables" | "queryTable" | "countTable" | "getDocument" | "runQuery";
|
|
50
|
+
export type ExplorerToolsConfig<Principal, Ctx, Scope extends string = string> = {
|
|
51
|
+
tables: TableCatalog;
|
|
52
|
+
/**
|
|
53
|
+
* Run a validated operation for `context.principal`. The executor MUST
|
|
54
|
+
* enforce per-user scoping; the kit only checks names and limits. Return
|
|
55
|
+
* the payload (a string is sent as-is, anything else as pretty JSON) or
|
|
56
|
+
* `null` to deny. A throw becomes an `isError` result with its message.
|
|
57
|
+
*/
|
|
58
|
+
execute: (op: ExplorerOperation, context: ToolContext<Principal, Ctx, Scope>) => Promise<JsonInput | null>;
|
|
59
|
+
/** Optional `run_query`. The kit validates arguments only; evaluation and scoping are the app's. */
|
|
60
|
+
runQuery?: (args: RunQueryArgs, context: ToolContext<Principal, Ctx, Scope>) => Promise<ToolResult>;
|
|
61
|
+
/** `limit` default and maximum for query_table. Default 100 and 1000 (app-b, app-c; app-d: 50 and 200). */
|
|
62
|
+
limits?: {
|
|
63
|
+
default?: number;
|
|
64
|
+
max?: number;
|
|
65
|
+
};
|
|
66
|
+
features?: {
|
|
67
|
+
/** `cursor` argument on query_table (app-b). Default false. */
|
|
68
|
+
cursor?: boolean;
|
|
69
|
+
/** `eq` / `gte` / `lte` on query_table and count_table (app-d). Default false. */
|
|
70
|
+
filters?: boolean;
|
|
71
|
+
/** get_document tool. Default true. */
|
|
72
|
+
getDocument?: boolean;
|
|
73
|
+
/** `tables` and `limitPerTable` on run_query (app-d). Default false. */
|
|
74
|
+
runQueryTables?: boolean;
|
|
75
|
+
};
|
|
76
|
+
names?: Partial<Record<ExplorerToolKey, string>>;
|
|
77
|
+
descriptions?: Partial<Record<ExplorerToolKey, string>>;
|
|
78
|
+
messages?: {
|
|
79
|
+
/**
|
|
80
|
+
* Unknown or malformed table, per tool. Default app-c's
|
|
81
|
+
* "Access denied: table "<t>" is not available via MCP.".
|
|
82
|
+
*/
|
|
83
|
+
unknownTable?: (table: string, tool: "query_table" | "count_table" | "run_query") => string;
|
|
84
|
+
/** Executor returned `null`. Default app-b's "Cannot query table "<t>". Use list_tables." and similar. */
|
|
85
|
+
denied?: (op: ExplorerOperation) => string;
|
|
86
|
+
/** get_document id is not `^[a-zA-Z0-9_]+$`. Default app-c's text. */
|
|
87
|
+
invalidId?: string;
|
|
88
|
+
};
|
|
89
|
+
/** Scopes the tools are on (app-a-style). Omit for all. */
|
|
90
|
+
scopes?: readonly Scope[];
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* list_tables, query_table, count_table, get_document and (with `runQuery`)
|
|
94
|
+
* run_query, backed by the app's user-scoped `execute` callback. Arguments
|
|
95
|
+
* and table names are checked before the callback runs, so a table outside
|
|
96
|
+
* the catalog never reaches it. The kit never reads the database.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createExplorerTools<Principal, Ctx, Scope extends string = string>(config: ExplorerToolsConfig<Principal, Ctx, Scope>): McpTool<Principal, Ctx, Scope>[];
|