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 | } from "./explorer-derived"; | 20 | } from "./explorer-derived"; |
| 21 | import { collectGroupKeys } from "@/lib/tool/explorer-grouping"; | 21 | import { collectGroupKeys } from "@/lib/tool/explorer-grouping"; |
| 22 | import { sourceLabel } from "@/lib/tool/source-utils"; | 22 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| 23 | import { traverseSchema } from "@/lib/tool/schema-traverse"; | ||
| 23 | import { | 24 | import { |
| 24 | EmptyState, | 25 | EmptyState, |
| 25 | LoadingState, | 26 | LoadingState, |
| @@ -38,6 +39,27 @@ import { warningsBySourceName } from "@/lib/tools/source-helpers"; · 12 unmodified lines | |||
| 38 | 39 | ||
| 39 | // ── Main Explorer ── | 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 | interface ToolExplorerProps { | 63 | interface ToolExplorerProps { |
| 42 | tools: ToolDescriptor[]; | 64 | tools: ToolDescriptor[]; |
| 43 | sources: ToolSourceRecord[]; | 65 | sources: ToolSourceRecord[]; |
| @@ -117,9 +139,10 @@ export function ToolExplorer({ · 73 unmodified lines | |||
| 117 | 139 | ||
| 118 | const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown"; | 140 | const hasInputHint = inputHint.length > 0 && inputHint !== "{}" && inputHint.toLowerCase() !== "unknown"; |
| 119 | const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; | 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 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); | 148 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); |
| @@ -371,8 +371,6 @@ function ToolDetailContent({ · 370 unmodified lines | |||
| 371 | const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; | 371 | const hasOutputHint = outputHint.length > 0 && outputHint.toLowerCase() !== "unknown"; |
| 372 | const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}"; | 372 | const hasInputSchema = inputSchemaJson.length > 0 && inputSchemaJson !== "{}"; |
| 373 | const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}"; | 373 | const hasOutputSchema = outputSchemaJson.length > 0 && outputSchemaJson !== "{}"; |
| 374 | const showInputHint = hasInputHint && !hasInputSchema; | ||
| 375 | const showOutputHint = hasOutputHint && !hasOutputSchema; | ||
| 376 | 374 | ||
| 377 | const inputFields = useMemo( | 375 | const inputFields = useMemo( |
| 378 | () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }), | 376 | () => traverseSchema(parseSchemaJson(inputSchemaJson), { maxEntries: 30, maxDepth: 5 }), |
| @@ -383,11 +381,20 @@ function ToolDetailContent({ · 4 unmodified lines | |||
| 383 | [outputSchemaJson], | 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 | const hasDetails = description.length > 0 | 393 | const hasDetails = description.length > 0 |
| 387 | || showInputHint | 394 | || shouldShowInputHint |
| 388 | || showOutputHint | 395 | || shouldShowOutputHint |
| 389 | || hasInputSchema | 396 | || canRenderInputSchema |
| 390 | || hasOutputSchema; | 397 | || canRenderOutputSchema; |
| 391 | const showLoading = Boolean(loading); | 398 | const showLoading = Boolean(loading); |
| 392 | 399 | ||
| 393 | return ( | 400 | return ( |
| @@ -415,11 +422,11 @@ function ToolDetailContent({ · 21 unmodified lines | |||
| 415 | ) : null} | 422 | ) : null} |
| 416 | 423 | ||
| 417 | {/* Type hint fallbacks (when no full schema is available) */} | 424 | {/* Type hint fallbacks (when no full schema is available) */} |
| 418 | {showInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null} | 425 | {shouldShowInputHint ? <TypeSignature raw={inputHint} label="Arguments" /> : null} |
| 419 | {showOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null} | 426 | {shouldShowOutputHint ? <TypeSignature raw={outputHint} label="Returns" /> : null} |
| 420 | 427 | ||
| 421 | {/* Structured schema rendering */} | 428 | {/* Structured schema rendering */} |
| 422 | {hasInputSchema ? ( | 429 | {canRenderInputSchema ? ( |
| 423 | <SchemaFieldsSection | 430 | <SchemaFieldsSection |
| 424 | label="Arguments" | 431 | label="Arguments" |
| 425 | entries={inputFields.entries} | 432 | entries={inputFields.entries} |
| @@ -428,7 +435,7 @@ function ToolDetailContent({ · 2 unmodified lines | |||
| 428 | /> | 435 | /> |
| 429 | ) : null} | 436 | ) : null} |
| 430 | 437 | ||
| 431 | {hasOutputSchema ? ( | 438 | {canRenderOutputSchema ? ( |
| 432 | <SchemaFieldsSection | 439 | <SchemaFieldsSection |
| 433 | label="Returns" | 440 | label="Returns" |
| 434 | entries={outputFields.entries} | 441 | entries={outputFields.entries} |
| @@ -11,6 +11,11 @@ import { api, internal } from "../_generated/api"; · 10 unmodified lines | |||
| 11 | import type { Id } from "../_generated/dataModel.d.ts"; | 11 | import type { Id } from "../_generated/dataModel.d.ts"; |
| 12 | import type { ActionCtx } from "../_generated/server"; | 12 | import type { ActionCtx } from "../_generated/server"; |
| 13 | 13 | ||
| 14 | type TaskWatchStatusPayload = { | ||
| 15 | status?: string; | ||
| 16 | pendingApprovalCount?: number; | ||
| 17 | }; | ||
| 18 | |||
| 14 | export function createMcpExecutorService(ctx: ActionCtx) { | 19 | export function createMcpExecutorService(ctx: ActionCtx) { |
| 15 | return { | 20 | return { |
| 16 | createTask: async (input: { | 21 | createTask: async (input: { |
| @@ -50,32 +55,77 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 33 unmodified lines | |||
| 50 | return () => {}; | 55 | return () => {}; |
| 51 | } | 56 | } |
| 52 | 57 | ||
| 53 | const client = new ConvexClient(callbackConvexUrl, { | 58 | const callbackArgs = { |
| 54 | skipConvexDeploymentUrlCheck: true, | 59 | internalSecret: callbackInternalSecret, |
| 55 | }); | 60 | runId: taskId, |
| 61 | workspaceId, | ||
| 62 | }; | ||
| 56 | let sequence = 0; | 63 | let sequence = 0; |
| 57 | const unsubscribe = client.onUpdate( | 64 | try { |
| 58 | api.runtimeCallbacks.getTaskWatchStatus, | 65 | const client = new ConvexClient(callbackConvexUrl, { |
| 59 | { | 66 | skipConvexDeploymentUrlCheck: true, |
| 60 | internalSecret: callbackInternalSecret, | 67 | }); |
| 61 | runId: taskId, | 68 | const unsubscribe = client.onUpdate( |
| 62 | workspaceId, | 69 | api.runtimeCallbacks.getTaskWatchStatus, |
| 63 | }, | 70 | callbackArgs, |
| 64 | (payload: unknown) => { | 71 | (payload: TaskWatchStatusPayload | null | undefined) => { |
| 65 | sequence += 1; | 72 | sequence += 1; |
| 66 | listener({ | 73 | listener({ |
| 67 | id: sequence, | 74 | id: sequence, |
| 68 | eventName: "task", | 75 | eventName: "task", |
| 69 | payload, | 76 | payload, |
| 70 | createdAt: Date.now(), | 77 | createdAt: Date.now(), |
| 71 | }); | 78 | }); |
| 72 | }, | 79 | }, |
| 73 | ); | 80 | ); |
| 74 | 81 | ||
| 75 | return () => { | 82 | return () => { |
| 76 | unsubscribe(); | 83 | unsubscribe(); |
| 77 | client.close(); | 84 | client.close(); |
| 78 | }; | 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 | bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => { | 130 | bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => { |
| 81 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { | 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(); | ||