This avoids rendering empty schema blocks in the tools explorer and adds polling fallback when websocket subscriptions are unavailable in MCP task watch streams. It also adds a security probe script to validate that untrusted runtime code cannot access token environment variables.
Unified · Split
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | } from "./explorer-derived"; |
| 21 | 21 | import { collectGroupKeys } from "@/lib/tool/explorer-grouping"; |
| 22 | 22 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| 23 | +import { traverseSchema } from "@/lib/tool/schema-traverse"; | |
| 23 | 24 | import { |
| 24 | 25 | EmptyState, |
| 25 | 26 | LoadingState, |
| @@ -38,6 +39,27 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines | ||
| 38 | 39 | |
| 39 | 40 | // ── Main Explorer ── |
| 40 | 41 | |
| 42 | +function asRecord(value: unknown): Record<string, unknown> { | |
| 43 | + return value && typeof value === "object" && !Array.isArray(value) | |
| 44 | + ? value as Record<string, unknown> | |
| 45 | + : {}; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function parseSchemaJson(value: string): Record<string, unknown> { | |
| 49 | + if (!value) return {}; | |
| 50 | + | |
| 51 | + try { | |
| 52 | + const parsed = JSON.parse(value) as unknown; | |
| 53 | + return asRecord(parsed); | |
| 54 | + } catch { | |
| 55 | + return {}; | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +function hasRenderableSchemaFields(schemaJson: string): boolean { | |
| 60 | + return traverseSchema(parseSchemaJson(schemaJson), { maxEntries: 1 }).entries.length > 0; | |
| 61 | +} | |
| 62 | + | |
| 41 | 63 | interface ToolExplorerProps { |
| 42 | 64 | tools: ToolDescriptor[]; |
| 43 | 65 | sources: ToolSourceRecord[]; |
| @@ -117,9 +139,10 @@ export function ToolExplorer({ · 73 unmodified lines | ||
| 117 | 139 | |
| 118 | 140 | const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown"; |
| 119 | 141 | const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; |
| 120 | − const hasSchemas = inputSchemaJson.length > 0 || outputSchemaJson.length > 0; | |
| 142 | + const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}" && hasRenderableSchemaFields(inputSchemaJson); | |
| 143 | + const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}" && hasRenderableSchemaFields(outputSchemaJson); | |
| 121 | 144 | |
| 122 | − return description.length > 0 || hasInputHint || hasOutputHint || hasSchemas; | |
| 145 | + return description.length > 0 || hasInputHint || hasOutputHint || hasInputSchema || hasOutputSchema; | |
| 123 | 146 | }, []); |
| 124 | 147 | |
| 125 | 148 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); |
| @@ -371,8 +371,6 @@ function ToolDetailContent({ · 370 unmodified lines | ||
| 371 | 371 | const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; |
| 372 | 372 | const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}"; |
| 373 | 373 | const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}"; |
| 374 | − const showInputHint = hasInputHint && !hasInputSchema; | |
| 375 | − const showOutputHint = hasOutputHint && !hasOutputSchema; | |
| 376 | 374 | |
| 377 | 375 | const inputFields = useMemo( |
| 378 | 376 | () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }), |
| @@ -383,11 +381,20 @@ function ToolDetailContent({ · 4 unmodified lines | ||
| 383 | 381 | [outputSchemaJson], |
| 384 | 382 | ); |
| 385 | 383 | |
| 384 | + const hasInputFields = inputFields.entries.length > 0; | |
| 385 | + const hasOutputFields = outputFields.entries.length > 0; | |
| 386 | + | |
| 387 | + const canRenderInputSchema = hasInputSchema && hasInputFields; | |
| 388 | + const canRenderOutputSchema = hasOutputSchema && hasOutputFields; | |
| 389 | + | |
| 390 | + const shouldShowInputHint = hasInputHint && !canRenderInputSchema; | |
| 391 | + const shouldShowOutputHint = hasOutputHint && !canRenderOutputSchema; | |
| 392 | + | |
| 386 | 393 | const hasDetails = description.length > 0 |
| 387 | − || showInputHint | |
| 388 | − || showOutputHint | |
| 389 | − || hasInputSchema | |
| 390 | − || hasOutputSchema; | |
| 394 | + || shouldShowInputHint | |
| 395 | + || shouldShowOutputHint | |
| 396 | + || canRenderInputSchema | |
| 397 | + || canRenderOutputSchema; | |
| 391 | 398 | const showLoading = Boolean(loading); |
| 392 | 399 | |
| 393 | 400 | return ( |
| @@ -415,11 +422,11 @@ function ToolDetailContent({ · 21 unmodified lines | ||
| 415 | 422 | ) : null} |
| 416 | 423 | |
| 417 | 424 | {/* Type hint fallbacks (when no full schema is available) */} |
| 418 | − {showInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null} | |
| 419 | − {showOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null} | |
| 425 | + {shouldShowInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null} | |
| 426 | + {shouldShowOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null} | |
| 420 | 427 | |
| 421 | 428 | {/* Structured schema rendering */} |
| 422 | − {hasInputSchema ? ( | |
| 429 | + {canRenderInputSchema ? ( | |
| 423 | 430 | <SchemaFieldsSection |
| 424 | 431 | label="Arguments" |
| 425 | 432 | entries={inputFields.entries} |
| @@ -428,7 +435,7 @@ function ToolDetailContent({ · 2 unmodified lines | ||
| 428 | 435 | /> |
| 429 | 436 | ) : null} |
| 430 | 437 | |
| 431 | − {hasOutputSchema ? ( | |
| 438 | + {canRenderOutputSchema ? ( | |
| 432 | 439 | <SchemaFieldsSection |
| 433 | 440 | label="Returns" |
| 434 | 441 | entries={outputFields.entries} |
| @@ -11,6 +11,11 @@ import { api, internal } from "../_generated/api"; · 10 unmodified lines | ||
| 11 | 11 | import type { Id } from "../_generated/dataModel.d.ts"; |
| 12 | 12 | import type { ActionCtx } from "../_generated/server"; |
| 13 | 13 | |
| 14 | +type TaskWatchStatusPayload = { | |
| 15 | + status?: string; | |
| 16 | + pendingApprovalCount?: number; | |
| 17 | +}; | |
| 18 | + | |
| 14 | 19 | export function createMcpExecutorService(ctx: ActionCtx) { |
| 15 | 20 | return { |
| 16 | 21 | createTask: async (input: { |
| @@ -50,32 +55,77 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 33 unmodified lines | ||
| 50 | 55 | return () => {}; |
| 51 | 56 | } |
| 52 | 57 | |
| 53 | − const client = new ConvexClient(callbackConvexUrl, { | |
| 54 | − skipConvexDeploymentUrlCheck: true, | |
| 55 | − }); | |
| 58 | + const callbackArgs = { | |
| 59 | + internalSecret: callbackInternalSecret, | |
| 60 | + runId: taskId, | |
| 61 | + workspaceId, | |
| 62 | + }; | |
| 56 | 63 | let sequence = 0; |
| 57 | − const unsubscribe = client.onUpdate( | |
| 58 | − api.runtimeCallbacks.getTaskWatchStatus, | |
| 59 | − { | |
| 60 | − internalSecret: callbackInternalSecret, | |
| 61 | − runId: taskId, | |
| 62 | − workspaceId, | |
| 63 | − }, | |
| 64 | − (payload: unknown) => { | |
| 65 | − sequence += 1; | |
| 66 | − listener({ | |
| 67 | − id: sequence, | |
| 68 | − eventName: "task", | |
| 69 | − payload, | |
| 70 | − createdAt: Date.now(), | |
| 71 | − }); | |
| 72 | − }, | |
| 73 | − ); | |
| 64 | + try { | |
| 65 | + const client = new ConvexClient(callbackConvexUrl, { | |
| 66 | + skipConvexDeploymentUrlCheck: true, | |
| 67 | + }); | |
| 68 | + const unsubscribe = client.onUpdate( | |
| 69 | + api.runtimeCallbacks.getTaskWatchStatus, | |
| 70 | + callbackArgs, | |
| 71 | + (payload: TaskWatchStatusPayload | null | undefined) => { | |
| 72 | + sequence += 1; | |
| 73 | + listener({ | |
| 74 | + id: sequence, | |
| 75 | + eventName: "task", | |
| 76 | + payload, | |
| 77 | + createdAt: Date.now(), | |
| 78 | + }); | |
| 79 | + }, | |
| 80 | + ); | |
| 74 | 81 | |
| 75 | − return () => { | |
| 76 | − unsubscribe(); | |
| 77 | − client.close(); | |
| 78 | − }; | |
| 82 | + return () => { | |
| 83 | + unsubscribe(); | |
| 84 | + client.close(); | |
| 85 | + }; | |
| 86 | + } catch { | |
| 87 | + // ConvexClient requires websocket support, which isn't always available in | |
| 88 | + // production HTTP-only Convex function hosts. Fall back to polling-based | |
| 89 | + // updates to avoid runtime failures. | |
| 90 | + const pollMs = 750; | |
| 91 | + let cancelled = false; | |
| 92 | + let timer: ReturnType<typeof setTimeout> | undefined; | |
| 93 | + | |
| 94 | + const poll = async () => { | |
| 95 | + if (cancelled) { | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + | |
| 99 | + try { | |
| 100 | + const payload = await ctx.runQuery(api.runtimeCallbacks.getTaskWatchStatus, callbackArgs); | |
| 101 | + sequence += 1; | |
| 102 | + listener({ | |
| 103 | + id: sequence, | |
| 104 | + eventName: "task", | |
| 105 | + payload: { | |
| 106 | + status: payload?.status, | |
| 107 | + pendingApprovalCount: payload?.pendingApprovalCount, | |
| 108 | + }, | |
| 109 | + createdAt: Date.now(), | |
| 110 | + }); | |
| 111 | + } catch { | |
| 112 | + // Ignore transient polling failures and continue retrying. | |
| 113 | + } | |
| 114 | + | |
| 115 | + if (!cancelled) { | |
| 116 | + timer = setTimeout(poll, pollMs); | |
| 117 | + } | |
| 118 | + }; | |
| 119 | + | |
| 120 | + void poll(); | |
| 121 | + | |
| 122 | + return () => { | |
| 123 | + cancelled = true; | |
| 124 | + if (timer) { | |
| 125 | + clearTimeout(timer); | |
| 126 | + } | |
| 127 | + }; | |
| 128 | + } | |
| 79 | 129 | }, |
| 80 | 130 | bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => { |
| 81 | 131 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { |
| @@ -0,0 +1,146 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | + | |
| 3 | +import { ConvexHttpClient } from "convex/browser"; | |
| 4 | +import { api } from "../../../packages/database/convex/_generated/api"; | |
| 5 | + | |
| 6 | +type RuntimeId = "local-bun" | "cloudflare-worker-loader"; | |
| 7 | + | |
| 8 | +function requireConvexUrl(): string { | |
| 9 | + const value = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL; | |
| 10 | + if (!value) { | |
| 11 | + throw new Error("Missing CONVEX_URL or CONVEX_SITE_URL"); | |
| 12 | + } | |
| 13 | + | |
| 14 | + return value; | |
| 15 | +} | |
| 16 | + | |
| 17 | +function buildProbeCode(): string { | |
| 18 | + return [ | |
| 19 | + "const out = {};", | |
| 20 | + "const summarize = (value) => {", | |
| 21 | + " if (value === undefined) return { type: 'undefined' };", | |
| 22 | + " if (value === null) return { type: 'null' };", | |
| 23 | + " const t = typeof value;", | |
| 24 | + " if (t === 'string') return { type: 'string', length: value.length, prefix: value.slice(0, 8) };", | |
| 25 | + " if (t === 'number' || t === 'boolean' || t === 'bigint') return { type: t, value };", | |
| 26 | + " if (t === 'function') return { type: 'function', name: value.name || '<anonymous>' };", | |
| 27 | + " if (t === 'object') {", | |
| 28 | + " try { return { type: 'object', keys: Object.keys(value).slice(0, 30) }; } catch { return { type: 'object' }; }", | |
| 29 | + " }", | |
| 30 | + " return { type: t };", | |
| 31 | + "};", | |
| 32 | + "const tokenKeys = ['CLOUDFLARE_SANDBOX_AUTH_TOKEN', 'EXECUTOR_INTERNAL_TOKEN', 'AUTH_TOKEN', 'CLOUDFLARE_SANDBOX_RUN_URL', 'CONVEX_URL'];", | |
| 33 | + "let g;", | |
| 34 | + "try { g = Function('return globalThis')(); } catch (error) { out.globalThisError = String(error); }", | |
| 35 | + "out.globalThis = summarize(g);", | |
| 36 | + "out.globalKeys = g ? Object.getOwnPropertyNames(g).slice(0, 80) : [];", | |
| 37 | + "let processRef;", | |
| 38 | + "try { processRef = Function(\"return typeof process !== 'undefined' ? process : undefined\")(); } catch (error) { out.processEvalError = String(error); }", | |
| 39 | + "out.process = summarize(processRef);", | |
| 40 | + "out.processEnv = summarize(processRef?.env);", | |
| 41 | + "const processEnvProbe = {};", | |
| 42 | + "for (const key of tokenKeys) {", | |
| 43 | + " let value;", | |
| 44 | + " try { value = processRef?.env?.[key]; } catch (error) { value = `<error:${String(error)}>`; }", | |
| 45 | + " processEnvProbe[key] = summarize(value);", | |
| 46 | + "}", | |
| 47 | + "out.processEnvProbe = processEnvProbe;", | |
| 48 | + "let bunRef;", | |
| 49 | + "try { bunRef = Function(\"return typeof Bun !== 'undefined' ? Bun : undefined\")(); } catch (error) { out.bunEvalError = String(error); }", | |
| 50 | + "out.Bun = summarize(bunRef);", | |
| 51 | + "let denoRef;", | |
| 52 | + "try { denoRef = Function(\"return typeof Deno !== 'undefined' ? Deno : undefined\")(); } catch (error) { out.denoEvalError = String(error); }", | |
| 53 | + "out.Deno = summarize(denoRef);", | |
| 54 | + "try {", | |
| 55 | + " const src = tools.discover?.toString?.() ?? '<missing>';", | |
| 56 | + " out.discoverToString = src.slice(0, 220);", | |
| 57 | + "} catch (error) {", | |
| 58 | + " out.discoverToStringError = String(error);", | |
| 59 | + "}", | |
| 60 | + "return out;", | |
| 61 | + ].join("\n"); | |
| 62 | +} | |
| 63 | + | |
| 64 | +function extractSuspiciousKeys(result: unknown): string[] { | |
| 65 | + const rec = (result && typeof result === "object") ? result as Record<string, unknown> : {}; | |
| 66 | + const probe = (rec.processEnvProbe && typeof rec.processEnvProbe === "object") | |
| 67 | + ? rec.processEnvProbe as Record<string, unknown> | |
| 68 | + : {}; | |
| 69 | + | |
| 70 | + const leaked: string[] = []; | |
| 71 | + for (const [key, value] of Object.entries(probe)) { | |
| 72 | + const summary = (value && typeof value === "object") ? value as Record<string, unknown> : {}; | |
| 73 | + const type = summary.type; | |
| 74 | + const length = summary.length; | |
| 75 | + if (type === "string" && typeof length === "number" && length > 0) { | |
| 76 | + leaked.push(key); | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + return leaked; | |
| 81 | +} | |
| 82 | + | |
| 83 | +async function runForRuntime( | |
| 84 | + client: ConvexHttpClient, | |
| 85 | + workspaceId: string, | |
| 86 | + sessionId: string, | |
| 87 | + runtimeId: RuntimeId, | |
| 88 | + code: string, | |
| 89 | +) { | |
| 90 | + const response = await client.action(api.executor.createTask, { | |
| 91 | + workspaceId: workspaceId as never, | |
| 92 | + sessionId, | |
| 93 | + runtimeId, | |
| 94 | + waitForResult: true, | |
| 95 | + timeoutMs: 120_000, | |
| 96 | + code, | |
| 97 | + metadata: { | |
| 98 | + purpose: "security-token-probe", | |
| 99 | + runtimeId, | |
| 100 | + }, | |
| 101 | + }) as { | |
| 102 | + task: { id: string; status: string; error?: string }; | |
| 103 | + result?: unknown; | |
| 104 | + }; | |
| 105 | + | |
| 106 | + return response; | |
| 107 | +} | |
| 108 | + | |
| 109 | +async function main() { | |
| 110 | + const client = new ConvexHttpClient(requireConvexUrl(), { | |
| 111 | + skipConvexDeploymentUrlCheck: true, | |
| 112 | + }); | |
| 113 | + | |
| 114 | + const sessionId = `mcp_token_probe_${crypto.randomUUID().slice(0, 8)}`; | |
| 115 | + const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, { sessionId }) as { | |
| 116 | + sessionId: string; | |
| 117 | + }; | |
| 118 | + const created = await client.mutation(api.organizations.create, { | |
| 119 | + sessionId: bootstrap.sessionId, | |
| 120 | + name: `Token Probe Org ${crypto.randomUUID().slice(0, 8)}`, | |
| 121 | + }) as { | |
| 122 | + workspace: { id: string }; | |
| 123 | + }; | |
| 124 | + const workspaceId = created.workspace.id; | |
| 125 | + const code = buildProbeCode(); | |
| 126 | + | |
| 127 | + for (const runtimeId of ["local-bun", "cloudflare-worker-loader"] as const) { | |
| 128 | + try { | |
| 129 | + const response = await runForRuntime(client, workspaceId, sessionId, runtimeId, code); | |
| 130 | + const leaked = extractSuspiciousKeys(response.result); | |
| 131 | + | |
| 132 | + console.log(`\n=== ${runtimeId} ===`); | |
| 133 | + console.log(`taskStatus=${response.task.status}`); | |
| 134 | + if (response.task.error) { | |
| 135 | + console.log(`taskError=${response.task.error}`); | |
| 136 | + } | |
| 137 | + console.log(`leakedKeys=${leaked.length > 0 ? leaked.join(",") : "none"}`); | |
| 138 | + console.log(JSON.stringify(response.result ?? null, null, 2)); | |
| 139 | + } catch (error) { | |
| 140 | + console.log(`\n=== ${runtimeId} FAILED ===`); | |
| 141 | + console.log(error instanceof Error ? error.message : String(error)); | |
| 142 | + } | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +await main(); | |