Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/providers/excalidraw_mcp/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import { createHash } from "node:crypto";
import { optionalRecord, optionalString, requiredString } from "../../core/cast.ts";
import { assertPublicHttpUrl, isPrivateNetworkAccessAllowed } from "../../core/request.ts";
Expand All @@ -30,6 +31,7 @@ type ExcalidrawMcpToolResult = {
};
const defaultEndpoint = "https://mcp.excalidraw.com";
const requestTimeoutMs = 30_000;
const excalidrawMcpJsonSchemaValidator = new CfWorkerJsonSchemaValidator();

export const excalidrawMcpActionHandlers: Record<string, ExcalidrawMcpActionHandler> = {
read_me(_input, context) {
Expand Down Expand Up @@ -153,7 +155,10 @@ async function withExcalidrawMcpClient<T>(
},
},
});
const client = new Client({ name: "oomol-connect-excalidraw-mcp", version: "1.0.0" });
const client = new Client(
{ name: "oomol-connect-excalidraw-mcp", version: "1.0.0" },
{ jsonSchemaValidator: excalidrawMcpJsonSchemaValidator },
);

try {
await client.connect(transport, { timeout: requestTimeoutMs, signal: context.signal });
Expand Down
13 changes: 9 additions & 4 deletions src/providers/flomo/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import { createHash } from "node:crypto";
import { optionalString, requiredString } from "../../core/cast.ts";
import {
Expand All @@ -30,6 +31,7 @@ const flomoWebhookPathPrefix = "/iwh/";
const flomoMcpEndpoint = "https://flomoapp.com/mcp";
const flomoMcpTokenField = "token";
const flomoRequestTimeoutMs = 30_000;
const flomoMcpJsonSchemaValidator = new CfWorkerJsonSchemaValidator();

type FlomoActionHandler = (input: Record<string, unknown>, context: FlomoActionContext) => Promise<unknown>;
type FlomoMcpToolResult = Awaited<ReturnType<Client["callTool"]>>;
Expand Down Expand Up @@ -441,10 +443,13 @@ async function withFlomoMcpClient<T>(
signal: input.signal,
},
});
const client = new Client({
name: "oomol-connect-flomo",
version: "1.0.0",
});
const client = new Client(
{
name: "oomol-connect-flomo",
version: "1.0.0",
},
{ jsonSchemaValidator: flomoMcpJsonSchemaValidator },
);

try {
await client.connect(transport, {
Expand Down
13 changes: 9 additions & 4 deletions src/providers/jin10/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import { createHash } from "node:crypto";
import {
defineApiKeyProviderExecutors,
Expand All @@ -19,6 +20,7 @@ const jin10McpOrigin = "https://mcp.jin10.com";
const jin10McpEndpoint = "https://mcp.jin10.com/mcp";
const jin10QuoteCodesResourceUri = "quote://codes";
const jin10RequestTimeoutMs = 30_000;
const jin10McpJsonSchemaValidator = new CfWorkerJsonSchemaValidator();

type Jin10ActionContext = Pick<ApiKeyProviderContext, "apiKey" | "fetcher" | "signal">;
type Jin10ActionHandler = (input: Record<string, unknown>, context: Jin10ActionContext) => Promise<unknown>;
Expand Down Expand Up @@ -176,10 +178,13 @@ async function withJin10McpClient<T>(
signal: input.signal,
},
});
const client = new Client({
name: "oomol-connect-jin10",
version: "1.0.0",
});
const client = new Client(
{
name: "oomol-connect-jin10",
version: "1.0.0",
},
{ jsonSchemaValidator: jin10McpJsonSchemaValidator },
);

try {
await client.connect(transport, {
Expand Down
6 changes: 3 additions & 3 deletions src/providers/jumpserver/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { jumpServerActions } from "./actions.ts";

const service = "jumpserver";

/** JumpServer provider backed by the official jumpserver/mcp SSE server. */
/** JumpServer provider backed by the official jumpserver/mcp server. */
export const provider: ProviderDefinition = {
service,
displayName: "JumpServer",
Expand All @@ -18,13 +18,13 @@ export const provider: ProviderDefinition = {
fields: [
{
key: "mcpEndpoint",
label: "MCP SSE Endpoint",
label: "MCP Endpoint",
inputType: "text",
required: true,
secret: false,
placeholder: "https://jumpserver-mcp.example.com/sse",
description:
"The SSE endpoint of the official jumpserver/mcp server. Public HTTPS endpoints are supported by default. Private-network, Tailscale, and NetBird endpoints require OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. Loopback endpoints remain blocked. See https://github.com/jumpserver/mcp.",
"The Streamable HTTP endpoint, or the legacy SSE endpoint exposed by official jumpserver/mcp deployments. Public HTTPS endpoints are supported by default. Private-network, Tailscale, and NetBird endpoints require OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. Loopback endpoints remain blocked. See https://github.com/jumpserver/mcp.",
Comment on lines +21 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Placeholder still models the legacy /sse endpoint.

The description now presents Streamable HTTP as primary, but placeholder (Line 25) still shows https://jumpserver-mcp.example.com/sse, which could nudge users toward configuring the legacy endpoint first. Consider using a /mcp-style example to match the now-preferred transport.

✏️ Suggested placeholder update
-          placeholder: "https://jumpserver-mcp.example.com/sse",
+          placeholder: "https://jumpserver-mcp.example.com/mcp",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
label: "MCP Endpoint",
inputType: "text",
required: true,
secret: false,
placeholder: "https://jumpserver-mcp.example.com/sse",
description:
"The SSE endpoint of the official jumpserver/mcp server. Public HTTPS endpoints are supported by default. Private-network, Tailscale, and NetBird endpoints require OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. Loopback endpoints remain blocked. See https://github.com/jumpserver/mcp.",
"The Streamable HTTP endpoint, or the legacy SSE endpoint exposed by official jumpserver/mcp deployments. Public HTTPS endpoints are supported by default. Private-network, Tailscale, and NetBird endpoints require OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. Loopback endpoints remain blocked. See https://github.com/jumpserver/mcp.",
label: "MCP Endpoint",
inputType: "text",
required: true,
secret: false,
placeholder: "https://jumpserver-mcp.example.com/mcp",
description:
"The Streamable HTTP endpoint, or the legacy SSE endpoint exposed by official jumpserver/mcp deployments. Public HTTPS endpoints are supported by default. Private-network, Tailscale, and NetBird endpoints require OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK. Loopback endpoints remain blocked. See https://github.com/jumpserver/mcp.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/jumpserver/definition.ts` around lines 21 - 27, Update the
placeholder in the MCP endpoint definition to use a `/mcp`-style HTTPS example
instead of the legacy `/sse` path, aligning it with the preferred Streamable
HTTP transport. Leave the surrounding label, description, and validation
settings unchanged.

},
{
key: "token",
Expand Down
139 changes: 139 additions & 0 deletions src/providers/jumpserver/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateJumpServerCredential } from "./runtime.ts";

afterEach(() => {
vi.unstubAllGlobals();
});

describe("JumpServer MCP runtime", () => {
it("uses Streamable HTTP with Worker-safe tool schema validation", async () => {
const credential = {
mcpEndpoint: "https://jumpserver.example.com/mcp",
token: "jumpserver-token",
};

// Warm the SDK's Node-only Zod fast path so this test isolates tool output validation.
await validateJumpServerCredential(credential, createStreamableMcpFetch());
vi.stubGlobal("Function", function disabledFunctionConstructor() {
throw new EvalError("Code generation from strings disallowed for this context");
});

const result = await validateJumpServerCredential(credential, createStreamableMcpFetch());

expect(result.metadata).toMatchObject({
mcpEndpoint: "https://jumpserver.example.com/mcp",
availableActions: ["assets_assets_list"],
});
});

it("falls back to legacy SSE when Streamable HTTP is unavailable", async () => {
const requests: Array<{ method: string; pathname: string }> = [];

const result = await validateJumpServerCredential(
{
mcpEndpoint: "https://jumpserver.example.com/sse",
token: "jumpserver-token",
},
createLegacySseMcpFetch(requests),
);

expect(result.metadata).toMatchObject({
mcpEndpoint: "https://jumpserver.example.com/sse",
availableActions: ["assets_assets_list"],
});
expect(requests).toContainEqual({ method: "POST", pathname: "/sse" });
expect(requests).toContainEqual({ method: "GET", pathname: "/sse" });
expect(requests).toContainEqual({ method: "POST", pathname: "/messages" });
});
});

function createStreamableMcpFetch(): typeof fetch {
return vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = init?.method ?? "GET";
if (method === "GET") {
return new Response(null, { status: 405 });
}

const request = readRequest(init);
if (!("id" in request)) {
return new Response(null, { status: 202 });
}

const result =
request.method === "initialize"
? {
protocolVersion: "2025-03-26",
capabilities: {},
serverInfo: { name: "jumpserver", version: "1.0.0" },
}
: {
tools: [
{
name: "assets_assets_list",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
},
],
};

return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), {
headers: {
"content-type": "application/json",
"mcp-session-id": "test-session",
},
});
}) as typeof fetch;
}

function createLegacySseMcpFetch(requests: Array<{ method: string; pathname: string }>): typeof fetch {
const encoder = new TextEncoder();
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;

return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : input.toString());
const method = init?.method ?? "GET";
requests.push({ method, pathname: url.pathname });

if (method === "POST" && url.pathname === "/sse") {
return new Response(null, { status: 405 });
}
if (method === "GET") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(encoder.encode("event: endpoint\ndata: /messages?session_id=test-session\n\n"));
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
}

const request = readRequest(init);
if ("id" in request) {
const result =
request.method === "initialize"
? {
protocolVersion: "2024-11-05",
capabilities: {},
serverInfo: { name: "jumpserver", version: "1.0.0" },
}
: {
tools: [
{
name: "assets_assets_list",
inputSchema: { type: "object" },
},
],
};
streamController!.enqueue(
encoder.encode(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n\n`),
);
}
return new Response(null, { status: 202 });
}) as typeof fetch;
}

function readRequest(init?: RequestInit): Record<string, unknown> {
return typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : {};
}
57 changes: 49 additions & 8 deletions src/providers/jumpserver/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { CredentialValidationResult } from "../../core/types.ts";
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import { createHash } from "node:crypto";
import { requiredString } from "../../core/cast.ts";
import { assertPublicHttpUrl, isPrivateNetworkAccessAllowed } from "../../core/request.ts";
Expand All @@ -21,6 +23,7 @@ export interface JumpServerMcpContext {
}

const requestTimeoutMs = 60_000;
const jumpServerMcpJsonSchemaValidator = new CfWorkerJsonSchemaValidator();

export const jumpServerActionHandlers: Record<string, JumpServerActionHandler> = {};
for (const toolName of jumpServerMcpToolNames) {
Expand Down Expand Up @@ -123,20 +126,58 @@ async function withJumpServerMcpClient<T>(
Authorization: `Bearer ${context.token}`,
"user-agent": providerUserAgent,
});
const transport = new SSEClientTransport(context.endpoint, {
fetch: context.fetcher,
requestInit: { headers, signal: context.signal },
});
const client = new Client({ name: "oomol-connect-jumpserver", version: "1.0.0" });
let client: Client | undefined;

try {
await client.connect(transport, { timeout: requestTimeoutMs });
client = await connectJumpServerMcpClient(context, headers);
return await run(client);
} catch (error) {
throw mapJumpServerMcpError(error);
} finally {
await client.close().catch(() => undefined);
await client?.close().catch(() => undefined);
}
}

async function connectJumpServerMcpClient(context: JumpServerMcpContext, headers: Headers): Promise<Client> {
const streamableClient = createJumpServerMcpClient();
const streamableTransport = new StreamableHTTPClientTransport(context.endpoint, {
fetch: context.fetcher,
requestInit: { headers, signal: context.signal },
});

try {
await streamableClient.connect(streamableTransport, { timeout: requestTimeoutMs });
return streamableClient;
} catch (error) {
await streamableClient.close().catch(() => undefined);
if (!isUnsupportedStreamableHttp(error)) {
throw error;
}
}

const legacyClient = createJumpServerMcpClient();
const legacyTransport = new SSEClientTransport(context.endpoint, {
fetch: context.fetcher,
requestInit: { headers, signal: context.signal },
});
try {
await legacyClient.connect(legacyTransport, { timeout: requestTimeoutMs });
return legacyClient;
} catch (error) {
await legacyClient.close().catch(() => undefined);
throw error;
}
}

function createJumpServerMcpClient(): Client {
return new Client(
{ name: "oomol-connect-jumpserver", version: "1.0.0" },
{ jsonSchemaValidator: jumpServerMcpJsonSchemaValidator },
);
}

function isUnsupportedStreamableHttp(error: unknown): boolean {
return error instanceof StreamableHTTPError && (error.code === 404 || error.code === 405);
}

function normalizeJumpServerMcpToolResult(toolName: string, result: JumpServerMcpToolResult): unknown {
Expand Down Expand Up @@ -182,7 +223,7 @@ function mapJumpServerMcpError(error: unknown): ProviderRequestError {
if (error instanceof UnauthorizedError) {
return new ProviderRequestError(401, "JumpServer MCP token is invalid or expired", error);
}
if (error instanceof SseError) {
if (error instanceof SseError || error instanceof StreamableHTTPError) {
const status = error.code;
return new ProviderRequestError(
status === 401 || status === 403 ? 401 : status && status >= 400 && status < 500 ? 400 : 502,
Expand Down
13 changes: 9 additions & 4 deletions src/providers/lingxing/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import { createHash } from "node:crypto";
import { optionalRecord, requiredString } from "../../core/cast.ts";
import { assertPublicHttpUrl } from "../../core/request.ts";
Expand All @@ -14,6 +15,7 @@ const lingxingMcpHost = "openmcp.lingxing.com";
const lingxingRequestTimeoutMs = 30_000;
const lingxingToolIntervalMs = 1_000;
const maximumTrackedRateLimitKeys = 1_024;
const lingxingMcpJsonSchemaValidator = new CfWorkerJsonSchemaValidator();

interface LingxingCredential {
endpoint: URL;
Expand Down Expand Up @@ -254,10 +256,13 @@ async function withLingxingMcpClient<T>(context: LingxingContext, run: (client:
signal: context.signal,
},
});
const client = new Client({
name: "oomol-connect-lingxing",
version: "1.0.0",
});
const client = new Client(
{
name: "oomol-connect-lingxing",
version: "1.0.0",
},
{ jsonSchemaValidator: lingxingMcpJsonSchemaValidator },
);

try {
await client.connect(transport, {
Expand Down
Loading
Loading