Unified · Split
| @@ -163,11 +163,7 @@ function createRunCodeTool( · 162 unmodified lines | ||
| 163 | 163 | // Input schema — when context is bound, workspace fields aren't needed |
| 164 | 164 | // --------------------------------------------------------------------------- |
| 165 | 165 | |
| 166 | −function toAnySchema(schema: unknown): AnySchema { | |
| 167 | − return schema as AnySchema; | |
| 168 | −} | |
| 169 | − | |
| 170 | −const FULL_INPUT = toAnySchema(z.object({ | |
| 166 | +const FULL_INPUT: AnySchema = z.object({ | |
| 171 | 167 | code: z.string().min(1), |
| 172 | 168 | timeoutMs: z.number().int().min(1).max(600_000).optional(), |
| 173 | 169 | runtimeId: z.string().optional(), |
| @@ -176,14 +172,14 @@ const FULL_INPUT = toAnySchema(z.object({ · 2 unmodified lines | ||
| 176 | 172 | sessionId: z.string().optional(), |
| 177 | 173 | waitForResult: z.boolean().optional(), |
| 178 | 174 | resultTimeoutMs: z.number().int().min(100).max(900_000).optional(), |
| 179 | −})); | |
| 175 | +}); | |
| 180 | 176 | |
| 181 | −const BOUND_INPUT = toAnySchema(z.object({ | |
| 177 | +const BOUND_INPUT: AnySchema = z.object({ | |
| 182 | 178 | code: z.string().min(1), |
| 183 | 179 | timeoutMs: z.number().int().min(1).max(600_000).optional(), |
| 184 | 180 | runtimeId: z.string().optional(), |
| 185 | 181 | metadata: z.record(z.string(), z.any()).optional(), |
| 186 | −})); | |
| 182 | +}); | |
| 187 | 183 | |
| 188 | 184 | // --------------------------------------------------------------------------- |
| 189 | 185 | // MCP server factory |
| @@ -207,13 +203,7 @@ async function createMcpServer( · 17 unmodified lines | ||
| 207 | 203 | }, |
| 208 | 204 | ); |
| 209 | 205 | const onApprovalPrompt = createMcpApprovalPrompt(mcp); |
| 210 | − const registerTool = (mcp.registerTool as ( | |
| 211 | − name: string, | |
| 212 | − config: { description: string; inputSchema: AnySchema }, | |
| 213 | − cb: ReturnType<typeof createRunCodeTool>, | |
| 214 | − ) => void).bind(mcp); | |
| 215 | − | |
| 216 | − registerTool( | |
| 206 | + mcp.registerTool( | |
| 217 | 207 | "run_code", |
| 218 | 208 | { |
| 219 | 209 | description: buildRunCodeDescription(), |
| @@ -17,9 +17,7 @@ export const createTaskEvent = internalMutation({ · 16 unmodified lines | ||
| 17 | 17 | throw new Error(`Task not found for event: ${args.taskId}`); |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | − const currentSequence = typeof (task as { nextEventSequence?: unknown }).nextEventSequence === "number" | |
| 21 | − ? (task as { nextEventSequence: number }).nextEventSequence | |
| 22 | − : 0; | |
| 20 | + const currentSequence = task.nextEventSequence ?? 0; | |
| 23 | 21 | const sequence = currentSequence + 1; |
| 24 | 22 | const createdAt = Date.now(); |
| 25 | 23 | |
| @@ -0,0 +1,51 @@ | ||
| 1 | +import { expect, test } from "bun:test"; | |
| 2 | +import { normalizeExternalToolSource } from "../runtime/tool_source_loading"; | |
| 3 | +import { normalizeToolSourceConfig } from "./tool_source_config"; | |
| 4 | + | |
| 5 | +test("normalizeToolSourceConfig returns an error result for invalid mcp config", () => { | |
| 6 | + const result = normalizeToolSourceConfig("mcp", {}); | |
| 7 | + | |
| 8 | + expect(result.isErr()).toBe(true); | |
| 9 | + if (result.isErr()) { | |
| 10 | + expect(result.error.message).toBe("Tool source url is required"); | |
| 11 | + } | |
| 12 | +}); | |
| 13 | + | |
| 14 | +test("normalizeToolSourceConfig trims and normalizes openapi auth/header values", () => { | |
| 15 | + const result = normalizeToolSourceConfig("openapi", { | |
| 16 | + spec: " https://example.com/openapi.json ", | |
| 17 | + baseUrl: " https://api.example.com ", | |
| 18 | + auth: { | |
| 19 | + type: "apiKey", | |
| 20 | + header: " x-api-key ", | |
| 21 | + value: "token-value", | |
| 22 | + }, | |
| 23 | + }); | |
| 24 | + | |
| 25 | + expect(result.isOk()).toBe(true); | |
| 26 | + if (result.isOk()) { | |
| 27 | + expect(result.value.spec).toBe("https://example.com/openapi.json"); | |
| 28 | + expect(result.value.baseUrl).toBe("https://api.example.com"); | |
| 29 | + expect(result.value.auth?.type).toBe("apiKey"); | |
| 30 | + if (result.value.auth?.type === "apiKey") { | |
| 31 | + expect(result.value.auth.header).toBe("x-api-key"); | |
| 32 | + expect(result.value.auth.value).toBe("token-value"); | |
| 33 | + } | |
| 34 | + } | |
| 35 | +}); | |
| 36 | + | |
| 37 | +test("normalizeExternalToolSource returns error result with source context", () => { | |
| 38 | + const result = normalizeExternalToolSource({ | |
| 39 | + id: "src_123", | |
| 40 | + type: "graphql", | |
| 41 | + name: "bad-graphql", | |
| 42 | + config: {}, | |
| 43 | + }); | |
| 44 | + | |
| 45 | + expect(result.isErr()).toBe(true); | |
| 46 | + if (result.isErr()) { | |
| 47 | + expect(result.error.message).toBe( | |
| 48 | + "Failed to normalize 'bad-graphql' source config: Tool source endpoint is required", | |
| 49 | + ); | |
| 50 | + } | |
| 51 | +}); | |
| @@ -1,9 +1,34 @@ | ||
| 1 | 1 | import { Result } from "better-result"; |
| 2 | −import type { OpenApiAuth } from "../../../core/src/tool/source-types"; | |
| 2 | +import type { | |
| 3 | + GraphqlToolSourceConfig, | |
| 4 | + McpToolSourceConfig, | |
| 5 | + OpenApiAuth, | |
| 6 | + OpenApiToolSourceConfig, | |
| 7 | +} from "../../../core/src/tool/source-types"; | |
| 3 | 8 | import type { ToolApprovalMode } from "../../../core/src/types"; |
| 4 | 9 | import { asRecord } from "../lib/object"; |
| 5 | 10 | |
| 6 | −type ToolSourceType = "mcp" | "openapi" | "graphql"; | |
| 11 | +export type ToolSourceType = "mcp" | "openapi" | "graphql"; | |
| 12 | + | |
| 13 | +export type NormalizedMcpToolSourceConfig = Pick< | |
| 14 | + McpToolSourceConfig, | |
| 15 | + "url" | "auth" | "discoveryHeaders" | "transport" | "queryParams" | "defaultApproval" | "overrides" | |
| 16 | +>; | |
| 17 | + | |
| 18 | +export type NormalizedGraphqlToolSourceConfig = Pick< | |
| 19 | + GraphqlToolSourceConfig, | |
| 20 | + "endpoint" | "schema" | "auth" | "defaultQueryApproval" | "defaultMutationApproval" | "overrides" | |
| 21 | +>; | |
| 22 | + | |
| 23 | +export type NormalizedOpenApiToolSourceConfig = Pick< | |
| 24 | + OpenApiToolSourceConfig, | |
| 25 | + "spec" | "collectionUrl" | "postmanProxyUrl" | "baseUrl" | "auth" | "defaultReadApproval" | "defaultWriteApproval" | "overrides" | |
| 26 | +>; | |
| 27 | + | |
| 28 | +export type NormalizedToolSourceConfig = | |
| 29 | + | NormalizedMcpToolSourceConfig | |
| 30 | + | NormalizedGraphqlToolSourceConfig | |
| 31 | + | NormalizedOpenApiToolSourceConfig; | |
| 7 | 32 | |
| 8 | 33 | function optionalTrimmedString(value: unknown): string | undefined { |
| 9 | 34 | if (typeof value !== "string") return undefined; |
| @@ -164,10 +189,26 @@ function normalizeSpec( · 154 unmodified lines | ||
| 164 | 189 | return Result.ok(specObject); |
| 165 | 190 | } |
| 166 | 191 | |
| 192 | +export function normalizeToolSourceConfig( | |
| 193 | + type: "mcp", | |
| 194 | + rawConfig: unknown, | |
| 195 | +): Result<NormalizedMcpToolSourceConfig, Error>; | |
| 196 | +export function normalizeToolSourceConfig( | |
| 197 | + type: "graphql", | |
| 198 | + rawConfig: unknown, | |
| 199 | +): Result<NormalizedGraphqlToolSourceConfig, Error>; | |
| 200 | +export function normalizeToolSourceConfig( | |
| 201 | + type: "openapi", | |
| 202 | + rawConfig: unknown, | |
| 203 | +): Result<NormalizedOpenApiToolSourceConfig, Error>; | |
| 204 | +export function normalizeToolSourceConfig( | |
| 205 | + type: ToolSourceType, | |
| 206 | + rawConfig: unknown, | |
| 207 | +): Result<NormalizedToolSourceConfig, Error>; | |
| 167 | 208 | export function normalizeToolSourceConfig( |
| 168 | 209 | type: ToolSourceType, |
| 169 | 210 | rawConfig: unknown, |
| 170 | −): Result<Record<string, unknown>, Error> { | |
| 211 | +): Result<NormalizedToolSourceConfig, Error> { | |
| 171 | 212 | const config = asRecord(rawConfig); |
| 172 | 213 | |
| 173 | 214 | if (type === "mcp") { |
| @@ -4,7 +4,7 @@ import type { ActionCtx } from "../../convex/_generated/server"; · 3 unmodified lines | ||
| 4 | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | 5 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; |
| 6 | 6 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; |
| 7 | −import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; | |
| 7 | +import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; | |
| 8 | 8 | import { asPayload } from "../lib/object"; |
| 9 | 9 | |
| 10 | 10 | export async function resolveCredentialHeaders( |
| @@ -15,7 +15,7 @@ export async function resolveCredentialHeaders( · 4 unmodified lines | ||
| 15 | 15 | const record = await ctx.runQuery(internal.database.resolveCredential, { |
| 16 | 16 | workspaceId: task.workspaceId, |
| 17 | 17 | sourceKey: spec.sourceKey, |
| 18 | − scope: spec.mode as CredentialScope, | |
| 18 | + scope: spec.mode, | |
| 19 | 19 | actorId: task.actorId, |
| 20 | 20 | }); |
| 21 | 21 | |
| @@ -25,25 +25,26 @@ export async function resolveCredentialHeaders( · 3 unmodified lines | ||
| 25 | 25 | if (!source) { |
| 26 | 26 | return null; |
| 27 | 27 | } |
| 28 | + const sourcePayload = asPayload(source); | |
| 28 | 29 | |
| 29 | 30 | const headers: Record<string, string> = {}; |
| 30 | 31 | if (spec.authType === "bearer") { |
| 31 | − const token = String((source as Record<string, unknown>).token ?? "").trim(); | |
| 32 | + const token = String(sourcePayload.token ?? "").trim(); | |
| 32 | 33 | if (token) headers.authorization = `Bearer ${token}`; |
| 33 | 34 | } else if (spec.authType === "apiKey") { |
| 34 | − const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key"); | |
| 35 | − const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim(); | |
| 35 | + const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key"); | |
| 36 | + const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim(); | |
| 36 | 37 | if (value) headers[headerName] = value; |
| 37 | 38 | } else if (spec.authType === "basic") { |
| 38 | − const username = String((source as Record<string, unknown>).username ?? ""); | |
| 39 | − const password = String((source as Record<string, unknown>).password ?? ""); | |
| 39 | + const username = String(sourcePayload.username ?? ""); | |
| 40 | + const password = String(sourcePayload.password ?? ""); | |
| 40 | 41 | if (username || password) { |
| 41 | 42 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); |
| 42 | 43 | headers.authorization = `Basic ${encoded}`; |
| 43 | 44 | } |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | − const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {}); | |
| 47 | + const bindingOverrides = asPayload(record?.overridesJson ?? {}); | |
| 47 | 48 | const overrideHeaders = asPayload(bindingOverrides.headers); |
| 48 | 49 | for (const [key, value] of Object.entries(overrideHeaders)) { |
| 49 | 50 | if (!key) continue; |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { Result } from "better-result"; | |
| 3 | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 5 | import { internal } from "../../convex/_generated/api"; |
| 5 | 6 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; |
| 6 | 7 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | 8 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 9 | +import { asPayload } from "../lib/object"; | |
| 8 | 10 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | 11 | import { getReadyRegistryBuildId } from "./tool_registry_state"; |
| 10 | 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| @@ -18,7 +20,7 @@ export function getGraphqlDecision( · 7 unmodified lines | ||
| 18 | 20 | policies: AccessPolicyRecord[], |
| 19 | 21 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 20 | 22 | const sourceName = tool._graphqlSource!; |
| 21 | − const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | |
| 23 | + const payload = asPayload(input); | |
| 22 | 24 | const queryString = typeof payload.query === "string" ? payload.query : ""; |
| 23 | 25 | |
| 24 | 26 | if (!queryString.trim()) { |
| @@ -95,13 +97,13 @@ export async function resolveToolForCall( · 70 unmodified lines | ||
| 95 | 97 | ctx: ActionCtx, |
| 96 | 98 | task: TaskRecord, |
| 97 | 99 | toolPath: string, |
| 98 | −): Promise<{ | |
| 100 | +): Promise<Result<{ | |
| 99 | 101 | tool: ToolDefinition; |
| 100 | 102 | resolvedToolPath: string; |
| 101 | −}> { | |
| 103 | +}, Error>> { | |
| 102 | 104 | const builtin = baseTools.get(toolPath); |
| 103 | 105 | if (builtin) { |
| 104 | − return { tool: builtin, resolvedToolPath: toolPath }; | |
| 106 | + return Result.ok({ tool: builtin, resolvedToolPath: toolPath }); | |
| 105 | 107 | } |
| 106 | 108 | |
| 107 | 109 | const buildId = await getReadyRegistryBuildId(ctx, { |
| @@ -137,14 +139,20 @@ export async function resolveToolForCall( · 29 unmodified lines | ||
| 137 | 139 | |
| 138 | 140 | if (!entry) { |
| 139 | 141 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); |
| 140 | − throw new Error(unknownToolErrorMessage(toolPath, suggestions)); | |
| 142 | + return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); | |
| 141 | 143 | } |
| 142 | 144 | |
| 143 | − const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool; | |
| 145 | + const serializedResult = Result.try(() => JSON.parse(entry.serializedToolJson) as SerializedTool); | |
| 146 | + if (serializedResult.isErr()) { | |
| 147 | + return Result.err( | |
| 148 | + new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`), | |
| 149 | + ); | |
| 150 | + } | |
| 151 | + const serialized = serializedResult.value; | |
| 144 | 152 | const [tool] = rehydrateTools([serialized], baseTools); |
| 145 | 153 | if (!tool) { |
| 146 | − throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`); | |
| 154 | + return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`)); | |
| 147 | 155 | } |
| 148 | 156 | |
| 149 | − return { tool, resolvedToolPath }; | |
| 157 | + return Result.ok({ tool, resolvedToolPath }); | |
| 150 | 158 | } |
| @@ -2,7 +2,10 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | −import { APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants"; | |
| 5 | +import { | |
| 6 | + APPROVAL_DENIED_PREFIX, | |
| 7 | + APPROVAL_PENDING_PREFIX, | |
| 8 | +} from "../../../core/src/execution-constants"; | |
| 6 | 9 | import type { |
| 7 | 10 | AccessPolicyRecord, |
| 8 | 11 | PolicyDecision, |
| @@ -66,237 +69,265 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 57 unmodified lines | ||
| 66 | 69 | })) as ToolCallRecord; |
| 67 | 70 | assertPersistedCallRunnable(persistedCall, callId); |
| 68 | 71 | |
| 69 | − const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | |
| 70 | − const typedPolicies = policies as AccessPolicyRecord[]; | |
| 71 | − | |
| 72 | − // Fast system tools are handled server-side from the registry. | |
| 73 | − if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { | |
| 74 | − const toolRegistry = internal.toolRegistry; | |
| 75 | − const buildId = await getReadyRegistryBuildId(ctx, { | |
| 76 | − workspaceId: task.workspaceId, | |
| 77 | − actorId: task.actorId, | |
| 78 | − clientId: task.clientId, | |
| 79 | − refreshOnStale: true, | |
| 80 | − }); | |
| 81 | − | |
| 82 | − const payload = typeof input === "string" | |
| 83 | − ? { query: input } | |
| 84 | − : (input && typeof input === "object" ? (input as Record<string, unknown>) : {}); | |
| 85 | − const isAllowed = (path: string, approval: ToolDefinition["approval"]) => | |
| 86 | − getDecisionForContext( | |
| 87 | − { path, approval, description: "", run: async () => null } as ToolDefinition, | |
| 88 | − { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId }, | |
| 89 | − typedPolicies, | |
| 90 | − ) !== "deny"; | |
| 91 | − | |
| 92 | − const normalizeHint = (value: unknown, fallback: string) => { | |
| 93 | − const str = typeof value === "string" ? value.trim() : ""; | |
| 94 | − return str.length > 0 ? str : fallback; | |
| 72 | + let effectiveToolPath = toolPath; | |
| 73 | + try { | |
| 74 | + const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | |
| 75 | + const typedPolicies = policies as AccessPolicyRecord[]; | |
| 76 | + const finalizeImmediateTool = async (value: unknown): Promise<unknown> => { | |
| 77 | + if (persistedCall.status === "requested") { | |
| 78 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | |
| 79 | + taskId: task.id, | |
| 80 | + callId, | |
| 81 | + toolPath, | |
| 82 | + approval: "auto", | |
| 83 | + }); | |
| 84 | + } | |
| 85 | + await completeToolCall(ctx, { | |
| 86 | + taskId: task.id, | |
| 87 | + callId, | |
| 88 | + toolPath, | |
| 89 | + }); | |
| 90 | + return value; | |
| 95 | 91 | }; |
| 96 | 92 | |
| 97 | − if (toolPath === "catalog.namespaces") { | |
| 98 | − const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200))); | |
| 99 | − const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, { | |
| 93 | + // Fast system tools are handled server-side from the registry. | |
| 94 | + if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { | |
| 95 | + const toolRegistry = internal.toolRegistry; | |
| 96 | + const buildId = await getReadyRegistryBuildId(ctx, { | |
| 100 | 97 | workspaceId: task.workspaceId, |
| 101 | − buildId, | |
| 102 | − limit, | |
| 103 | − }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; | |
| 104 | − return { namespaces, total: namespaces.length }; | |
| 105 | − } | |
| 98 | + actorId: task.actorId, | |
| 99 | + clientId: task.clientId, | |
| 100 | + refreshOnStale: true, | |
| 101 | + }); | |
| 106 | 102 | |
| 107 | − if (toolPath === "catalog.tools") { | |
| 108 | − const namespace = String(payload.namespace ?? "").trim().toLowerCase(); | |
| 109 | − const query = String(payload.query ?? "").trim(); | |
| 110 | − const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50))); | |
| 111 | − | |
| 112 | − const raw = query | |
| 113 | − ? await ctx.runQuery(toolRegistry.searchTools, { | |
| 114 | − workspaceId: task.workspaceId, | |
| 115 | − buildId, | |
| 116 | − query, | |
| 117 | − limit, | |
| 118 | − }) | |
| 119 | − : namespace | |
| 120 | − ? await ctx.runQuery(toolRegistry.listToolsByNamespace, { | |
| 103 | + const payload = typeof input === "string" | |
| 104 | + ? { query: input } | |
| 105 | + : asPayload(input); | |
| 106 | + const isAllowed = (path: string, approval: ToolDefinition["approval"]) => { | |
| 107 | + const policyProbeTool: ToolDefinition = { | |
| 108 | + path, | |
| 109 | + approval, | |
| 110 | + description: "", | |
| 111 | + run: async () => null, | |
| 112 | + }; | |
| 113 | + return getDecisionForContext( | |
| 114 | + policyProbeTool, | |
| 115 | + { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId }, | |
| 116 | + typedPolicies, | |
| 117 | + ) !== "deny"; | |
| 118 | + }; | |
| 119 | + | |
| 120 | + const normalizeHint = (value: unknown, fallback: string) => { | |
| 121 | + const str = typeof value === "string" ? value.trim() : ""; | |
| 122 | + return str.length > 0 ? str : fallback; | |
| 123 | + }; | |
| 124 | + | |
| 125 | + if (toolPath === "catalog.namespaces") { | |
| 126 | + const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200))); | |
| 127 | + const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, { | |
| 128 | + workspaceId: task.workspaceId, | |
| 129 | + buildId, | |
| 130 | + limit, | |
| 131 | + }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; | |
| 132 | + return await finalizeImmediateTool({ namespaces, total: namespaces.length }); | |
| 133 | + } | |
| 134 | + | |
| 135 | + if (toolPath === "catalog.tools") { | |
| 136 | + const namespace = String(payload.namespace ?? "").trim().toLowerCase(); | |
| 137 | + const query = String(payload.query ?? "").trim(); | |
| 138 | + const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50))); | |
| 139 | + | |
| 140 | + const raw = query | |
| 141 | + ? await ctx.runQuery(toolRegistry.searchTools, { | |
| 121 | 142 | workspaceId: task.workspaceId, |
| 122 | 143 | buildId, |
| 123 | − namespace, | |
| 144 | + query, | |
| 124 | 145 | limit, |
| 125 | 146 | }) |
| 126 | − : []; | |
| 147 | + : namespace | |
| 148 | + ? await ctx.runQuery(toolRegistry.listToolsByNamespace, { | |
| 149 | + workspaceId: task.workspaceId, | |
| 150 | + buildId, | |
| 151 | + namespace, | |
| 152 | + limit, | |
| 153 | + }) | |
| 154 | + : []; | |
| 155 | + | |
| 156 | + const results = (raw as RegistryToolEntry[]) | |
| 157 | + .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`)) | |
| 158 | + .filter((entry) => isAllowed(entry.path, entry.approval)) | |
| 159 | + .slice(0, limit) | |
| 160 | + .map((entry) => { | |
| 161 | + const preferredPath = entry.preferredPath ?? entry.path; | |
| 162 | + return { | |
| 163 | + path: preferredPath, | |
| 164 | + source: entry.source, | |
| 165 | + approval: entry.approval, | |
| 166 | + description: entry.description, | |
| 167 | + input: normalizeHint(entry.displayInput, "{}"), | |
| 168 | + output: normalizeHint(entry.displayOutput, "unknown"), | |
| 169 | + // required keys are encoded in the `input` type hint | |
| 170 | + }; | |
| 171 | + }); | |
| 172 | + | |
| 173 | + return await finalizeImmediateTool({ results, total: results.length }); | |
| 174 | + } | |
| 175 | + | |
| 176 | + // discover | |
| 177 | + const query = String(payload.query ?? "").trim(); | |
| 178 | + const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); | |
| 179 | + const compact = payload.compact === false ? false : true; | |
| 180 | + const hits = await ctx.runQuery(toolRegistry.searchTools, { | |
| 181 | + workspaceId: task.workspaceId, | |
| 182 | + buildId, | |
| 183 | + query, | |
| 184 | + limit: Math.max(limit * 2, limit), | |
| 185 | + }) as RegistryToolEntry[]; | |
| 127 | 186 | |
| 128 | − const results = (raw as RegistryToolEntry[]) | |
| 129 | − .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`)) | |
| 187 | + const filtered = hits | |
| 130 | 188 | .filter((entry) => isAllowed(entry.path, entry.approval)) |
| 131 | − .slice(0, limit) | |
| 132 | − .map((entry) => { | |
| 133 | − const preferredPath = entry.preferredPath ?? entry.path; | |
| 134 | − return { | |
| 135 | − path: preferredPath, | |
| 136 | − source: entry.source, | |
| 137 | − approval: entry.approval, | |
| 138 | − description: entry.description, | |
| 139 | − input: normalizeHint(entry.displayInput, "{}"), | |
| 140 | − output: normalizeHint(entry.displayOutput, "unknown"), | |
| 141 | − // required keys are encoded in the `input` type hint | |
| 142 | − }; | |
| 143 | − }); | |
| 144 | − | |
| 145 | − return { results, total: results.length }; | |
| 146 | − } | |
| 147 | − | |
| 148 | − // discover | |
| 149 | − const query = String(payload.query ?? "").trim(); | |
| 150 | − const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); | |
| 151 | − const compact = payload.compact === false ? false : true; | |
| 152 | − const hits = await ctx.runQuery(toolRegistry.searchTools, { | |
| 153 | − workspaceId: task.workspaceId, | |
| 154 | − buildId, | |
| 155 | − query, | |
| 156 | − limit: Math.max(limit * 2, limit), | |
| 157 | − }) as RegistryToolEntry[]; | |
| 158 | − | |
| 159 | − const filtered = hits | |
| 160 | − .filter((entry) => isAllowed(entry.path, entry.approval)) | |
| 161 | − .slice(0, limit); | |
| 162 | − | |
| 163 | − const results = filtered.map((entry) => { | |
| 164 | − const preferredPath = entry.preferredPath ?? entry.path; | |
| 165 | − const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description; | |
| 166 | − return { | |
| 167 | − path: preferredPath, | |
| 168 | − source: entry.source, | |
| 169 | − approval: entry.approval, | |
| 170 | − description, | |
| 171 | − input: normalizeHint(entry.displayInput, "{}"), | |
| 172 | − output: normalizeHint(entry.displayOutput, "unknown"), | |
| 173 | − // required keys are encoded in the `input` type hint | |
| 174 | − }; | |
| 175 | − }); | |
| 176 | − | |
| 177 | − const bestPath = results[0]?.path ?? null; | |
| 178 | − return { | |
| 179 | − bestPath, | |
| 180 | − results, | |
| 181 | − total: results.length, | |
| 182 | − }; | |
| 183 | − } | |
| 184 | − | |
| 185 | − let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath); | |
| 189 | + .slice(0, limit); | |
| 190 | + | |
| 191 | + const results = filtered.map((entry) => { | |
| 192 | + const preferredPath = entry.preferredPath ?? entry.path; | |
| 193 | + const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description; | |
| 194 | + return { | |
| 195 | + path: preferredPath, | |
| 196 | + source: entry.source, | |
| 197 | + approval: entry.approval, | |
| 198 | + description, | |
| 199 | + input: normalizeHint(entry.displayInput, "{}"), | |
| 200 | + output: normalizeHint(entry.displayOutput, "unknown"), | |
| 201 | + // required keys are encoded in the `input` type hint | |
| 202 | + }; | |
| 203 | + }); | |
| 186 | 204 | |
| 187 | − let decision: PolicyDecision; | |
| 188 | − let effectiveToolPath = resolvedToolPath; | |
| 189 | − if (tool._graphqlSource) { | |
| 190 | − const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies); | |
| 191 | − decision = result.decision; | |
| 192 | − if (result.effectivePaths.length > 0) { | |
| 193 | − effectiveToolPath = result.effectivePaths.join(", "); | |
| 205 | + const bestPath = results[0]?.path ?? null; | |
| 206 | + return await finalizeImmediateTool({ | |
| 207 | + bestPath, | |
| 208 | + results, | |
| 209 | + total: results.length, | |
| 210 | + }); | |
| 194 | 211 | } |
| 195 | − } else { | |
| 196 | − decision = getToolDecision(task, tool, typedPolicies); | |
| 197 | − } | |
| 198 | − | |
| 199 | − const publishToolStarted = persistedCall.status === "requested"; | |
| 200 | 212 | |
| 201 | − if (decision === "deny") { | |
| 202 | − const deniedMessage = `${effectiveToolPath} (policy denied)`; | |
| 203 | − await denyToolCall(ctx, { | |
| 204 | − task, | |
| 205 | − callId, | |
| 206 | − toolPath: effectiveToolPath, | |
| 207 | − deniedMessage, | |
| 208 | − reason: "policy_deny", | |
| 209 | − }); | |
| 210 | − } | |
| 211 | − | |
| 212 | − let credential: ResolvedToolCredential | undefined; | |
| 213 | − if (tool.credential) { | |
| 214 | − const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | |
| 215 | − if (!resolved) { | |
| 216 | − throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | |
| 213 | + const resolvedToolResult = await resolveToolForCall(ctx, task, toolPath); | |
| 214 | + if (resolvedToolResult.isErr()) { | |
| 215 | + throw resolvedToolResult.error; | |
| 217 | 216 | } |
| 218 | − credential = resolved; | |
| 219 | − } | |
| 220 | − | |
| 221 | − if (publishToolStarted) { | |
| 222 | − await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | |
| 223 | − taskId: task.id, | |
| 224 | − callId, | |
| 225 | − toolPath: effectiveToolPath, | |
| 226 | − approval: decision === "require_approval" ? "required" : "auto", | |
| 227 | − }); | |
| 228 | − } | |
| 229 | − | |
| 230 | − let approvalSatisfied = false; | |
| 231 | − if (persistedCall.approvalId) { | |
| 232 | − const existingApproval = await ctx.runQuery(internal.database.getApproval, { | |
| 233 | − approvalId: persistedCall.approvalId, | |
| 234 | − }); | |
| 235 | − if (!existingApproval) { | |
| 236 | − throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | |
| 217 | + const { tool, resolvedToolPath } = resolvedToolResult.value; | |
| 218 | + | |
| 219 | + let decision: PolicyDecision; | |
| 220 | + effectiveToolPath = resolvedToolPath; | |
| 221 | + if (tool._graphqlSource) { | |
| 222 | + const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies); | |
| 223 | + decision = result.decision; | |
| 224 | + if (result.effectivePaths.length > 0) { | |
| 225 | + effectiveToolPath = result.effectivePaths.join(", "); | |
| 226 | + } | |
| 227 | + } else { | |
| 228 | + decision = getToolDecision(task, tool, typedPolicies); | |
| 237 | 229 | } |
| 238 | 230 | |
| 239 | − if (existingApproval.status === "pending") { | |
| 240 | − throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | |
| 241 | − } | |
| 231 | + const publishToolStarted = persistedCall.status === "requested"; | |
| 242 | 232 | |
| 243 | − if (existingApproval.status === "denied") { | |
| 244 | − await denyToolCallForApproval(ctx, { | |
| 233 | + if (decision === "deny") { | |
| 234 | + const deniedMessage = `${effectiveToolPath} (policy denied)`; | |
| 235 | + await denyToolCall(ctx, { | |
| 245 | 236 | task, |
| 246 | 237 | callId, |
| 247 | 238 | toolPath: effectiveToolPath, |
| 248 | − approvalId: existingApproval.id, | |
| 239 | + deniedMessage, | |
| 240 | + reason: "policy_deny", | |
| 249 | 241 | }); |
| 250 | 242 | } |
| 251 | 243 | |
| 252 | − approvalSatisfied = existingApproval.status === "approved"; | |
| 253 | − } | |
| 254 | − | |
| 255 | − if (decision === "require_approval" && !approvalSatisfied) { | |
| 256 | − const approvalId = persistedCall.approvalId ?? createApprovalId(); | |
| 257 | − let approval = await ctx.runQuery(internal.database.getApproval, { | |
| 258 | − approvalId, | |
| 259 | − }); | |
| 244 | + let credential: ResolvedToolCredential | undefined; | |
| 245 | + if (tool.credential) { | |
| 246 | + const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | |
| 247 | + if (!resolved) { | |
| 248 | + throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | |
| 249 | + } | |
| 250 | + credential = resolved; | |
| 251 | + } | |
| 260 | 252 | |
| 261 | − if (!approval) { | |
| 262 | − approval = await ctx.runMutation(internal.database.createApproval, { | |
| 263 | − id: approvalId, | |
| 253 | + if (publishToolStarted) { | |
| 254 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | |
| 264 | 255 | taskId: task.id, |
| 256 | + callId, | |
| 265 | 257 | toolPath: effectiveToolPath, |
| 266 | − input, | |
| 258 | + approval: decision === "require_approval" ? "required" : "auto", | |
| 267 | 259 | }); |
| 260 | + } | |
| 268 | 261 | |
| 269 | − await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { | |
| 270 | − approvalId: approval.id, | |
| 271 | − taskId: task.id, | |
| 272 | − callId, | |
| 273 | − toolPath: approval.toolPath, | |
| 274 | − input: asPayload(approval.input), | |
| 275 | − createdAt: approval.createdAt, | |
| 262 | + let approvalSatisfied = false; | |
| 263 | + if (persistedCall.approvalId) { | |
| 264 | + const existingApproval = await ctx.runQuery(internal.database.getApproval, { | |
| 265 | + approvalId: persistedCall.approvalId, | |
| 276 | 266 | }); |
| 267 | + if (!existingApproval) { | |
| 268 | + throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | |
| 269 | + } | |
| 270 | + | |
| 271 | + if (existingApproval.status === "pending") { | |
| 272 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | |
| 273 | + } | |
| 274 | + | |
| 275 | + if (existingApproval.status === "denied") { | |
| 276 | + await denyToolCallForApproval(ctx, { | |
| 277 | + task, | |
| 278 | + callId, | |
| 279 | + toolPath: effectiveToolPath, | |
| 280 | + approvalId: existingApproval.id, | |
| 281 | + }); | |
| 282 | + } | |
| 283 | + | |
| 284 | + approvalSatisfied = existingApproval.status === "approved"; | |
| 277 | 285 | } |
| 278 | 286 | |
| 279 | − await ctx.runMutation(internal.database.setToolCallPendingApproval, { | |
| 280 | − taskId: task.id, | |
| 281 | − callId, | |
| 282 | − approvalId: approval.id, | |
| 283 | − }); | |
| 287 | + if (decision === "require_approval" && !approvalSatisfied) { | |
| 288 | + const approvalId = persistedCall.approvalId ?? createApprovalId(); | |
| 289 | + let approval = await ctx.runQuery(internal.database.getApproval, { | |
| 290 | + approvalId, | |
| 291 | + }); | |
| 284 | 292 | |
| 285 | − if (approval.status === "pending") { | |
| 286 | − throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | |
| 287 | − } | |
| 293 | + if (!approval) { | |
| 294 | + approval = await ctx.runMutation(internal.database.createApproval, { | |
| 295 | + id: approvalId, | |
| 296 | + taskId: task.id, | |
| 297 | + toolPath: effectiveToolPath, | |
| 298 | + input, | |
| 299 | + }); | |
| 288 | 300 | |
| 289 | − if (approval.status === "denied") { | |
| 290 | − await denyToolCallForApproval(ctx, { | |
| 291 | − task, | |
| 301 | + await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { | |
| 302 | + approvalId: approval.id, | |
| 303 | + taskId: task.id, | |
| 304 | + callId, | |
| 305 | + toolPath: approval.toolPath, | |
| 306 | + input: asPayload(approval.input), | |
| 307 | + createdAt: approval.createdAt, | |
| 308 | + }); | |
| 309 | + } | |
| 310 | + | |
| 311 | + await ctx.runMutation(internal.database.setToolCallPendingApproval, { | |
| 312 | + taskId: task.id, | |
| 292 | 313 | callId, |
| 293 | − toolPath: effectiveToolPath, | |
| 294 | 314 | approvalId: approval.id, |
| 295 | 315 | }); |
| 316 | + | |
| 317 | + if (approval.status === "pending") { | |
| 318 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | |
| 319 | + } | |
| 320 | + | |
| 321 | + if (approval.status === "denied") { | |
| 322 | + await denyToolCallForApproval(ctx, { | |
| 323 | + task, | |
| 324 | + callId, | |
| 325 | + toolPath: effectiveToolPath, | |
| 326 | + approvalId: approval.id, | |
| 327 | + }); | |
| 328 | + } | |
| 296 | 329 | } |
| 297 | − } | |
| 298 | 330 | |
| 299 | − try { | |
| 300 | 331 | const context: ToolRunContext = { |
| 301 | 332 | taskId: task.id, |
| 302 | 333 | workspaceId: task.workspaceId, |
| @@ -315,12 +346,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 12 unmodified lines | ||
| 315 | 346 | return value; |
| 316 | 347 | } catch (error) { |
| 317 | 348 | const message = describeError(error); |
| 318 | − await failToolCall(ctx, { | |
| 319 | − taskId: task.id, | |
| 320 | − callId, | |
| 321 | − error: message, | |
| 322 | − toolPath: effectiveToolPath, | |
| 323 | − }); | |
| 349 | + const isApprovalControlSignal = | |
| 350 | + message.startsWith(APPROVAL_PENDING_PREFIX) || | |
| 351 | + message.startsWith(APPROVAL_DENIED_PREFIX); | |
| 352 | + | |
| 353 | + if (!isApprovalControlSignal) { | |
| 354 | + await failToolCall(ctx, { | |
| 355 | + taskId: task.id, | |
| 356 | + callId, | |
| 357 | + error: message, | |
| 358 | + toolPath: effectiveToolPath, | |
| 359 | + }); | |
| 360 | + } | |
| 361 | + | |
| 324 | 362 | throw error; |
| 325 | 363 | } |
| 326 | 364 | } |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 5 | 5 | import { internal } from "../../convex/_generated/api"; |
| 6 | +import { asPayload } from "../lib/object"; | |
| 6 | 7 | import { sourceSignature } from "./tool_source_loading"; |
| 7 | 8 | |
| 8 | 9 | export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|"; |
| @@ -26,14 +27,52 @@ type ToolSourceState = { · 17 unmodified lines | ||
| 26 | 27 | enabled: boolean; |
| 27 | 28 | }; |
| 28 | 29 | |
| 30 | +function toRegistryState(value: unknown): RegistryState { | |
| 31 | + if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + | |
| 35 | + const state = asPayload(value); | |
| 36 | + if (typeof state.signature !== "string") { | |
| 37 | + return null; | |
| 38 | + } | |
| 39 | + | |
| 40 | + return { | |
| 41 | + signature: state.signature, | |
| 42 | + readyBuildId: typeof state.readyBuildId === "string" ? state.readyBuildId : undefined, | |
| 43 | + }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function toToolSourceStateList(value: unknown): ToolSourceState[] { | |
| 47 | + if (!Array.isArray(value)) { | |
| 48 | + return []; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const normalized: ToolSourceState[] = []; | |
| 52 | + for (const entry of value) { | |
| 53 | + const record = asPayload(entry); | |
| 54 | + if (typeof record.id !== "string") continue; | |
| 55 | + if (typeof record.updatedAt !== "number") continue; | |
| 56 | + normalized.push({ | |
| 57 | + id: record.id, | |
| 58 | + updatedAt: record.updatedAt, | |
| 59 | + enabled: record.enabled !== false, | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + | |
| 63 | + return normalized; | |
| 64 | +} | |
| 65 | + | |
| 29 | 66 | async function readRegistryState( |
| 30 | 67 | ctx: Pick<ActionCtx, "runQuery">, |
| 31 | 68 | workspaceId: Id<"workspaces">, |
| 32 | 69 | ): Promise<{ buildId?: string; isReady: boolean }> { |
| 33 | − const [state, sources] = await Promise.all([ | |
| 34 | − ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>, | |
| 35 | − ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>, | |
| 70 | + const [rawState, rawSources] = await Promise.all([ | |
| 71 | + ctx.runQuery(internal.toolRegistry.getState, { workspaceId }), | |
| 72 | + ctx.runQuery(internal.database.listToolSources, { workspaceId }), | |
| 36 | 73 | ]); |
| 74 | + const state = toRegistryState(rawState); | |
| 75 | + const sources = toToolSourceStateList(rawSources); | |
| 37 | 76 | |
| 38 | 77 | const expectedSignature = registrySignatureForWorkspace(workspaceId, sources); |
| 39 | 78 | const buildId = state?.readyBuildId; |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { Result } from "better-result"; | |
| 3 | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | 5 | import { internal } from "../../convex/_generated/api"; |
| 5 | 6 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| @@ -40,58 +41,77 @@ export function normalizeExternalToolSource(raw: { · 34 unmodified lines | ||
| 40 | 41 | type: ToolSourceRecord["type"]; |
| 41 | 42 | name: string; |
| 42 | 43 | config: Record<string, unknown>; |
| 43 | −}): ExternalToolSourceConfig { | |
| 44 | − const configResult = normalizeToolSourceConfig(raw.type, raw.config); | |
| 45 | − if (configResult.isErr()) { | |
| 46 | − throw new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`); | |
| 47 | − } | |
| 48 | − const config = configResult.value; | |
| 44 | +}): Result<ExternalToolSourceConfig, Error> { | |
| 49 | 45 | |
| 50 | 46 | if (raw.type === "mcp") { |
| 47 | + const configResult = normalizeToolSourceConfig("mcp", raw.config); | |
| 48 | + if (configResult.isErr()) { | |
| 49 | + return Result.err( | |
| 50 | + new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | |
| 51 | + ); | |
| 52 | + } | |
| 53 | + const config = configResult.value; | |
| 51 | 54 | const result: McpToolSourceConfig = { |
| 52 | 55 | type: "mcp", |
| 53 | 56 | name: raw.name, |
| 54 | 57 | sourceId: raw.id, |
| 55 | 58 | sourceKey: `source:${raw.id}`, |
| 56 | − url: String(config.url), | |
| 57 | − auth: config.auth as McpToolSourceConfig["auth"], | |
| 58 | − transport: config.transport as McpToolSourceConfig["transport"], | |
| 59 | − queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | |
| 60 | − defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], | |
| 61 | − overrides: config.overrides as McpToolSourceConfig["overrides"], | |
| 59 | + url: config.url, | |
| 60 | + auth: config.auth, | |
| 61 | + transport: config.transport, | |
| 62 | + queryParams: config.queryParams, | |
| 63 | + defaultApproval: config.defaultApproval, | |
| 64 | + overrides: config.overrides, | |
| 62 | 65 | }; |
| 63 | − return result; | |
| 66 | + return Result.ok(result); | |
| 64 | 67 | } |
| 65 | 68 | |
| 66 | 69 | if (raw.type === "graphql") { |
| 70 | + const configResult = normalizeToolSourceConfig("graphql", raw.config); | |
| 71 | + if (configResult.isErr()) { | |
| 72 | + return Result.err( | |
| 73 | + new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | |
| 74 | + ); | |
| 75 | + } | |
| 76 | + const config = configResult.value; | |
| 67 | 77 | const result: GraphqlToolSourceConfig = { |
| 68 | 78 | type: "graphql", |
| 69 | 79 | name: raw.name, |
| 70 | 80 | sourceId: raw.id, |
| 71 | 81 | sourceKey: `source:${raw.id}`, |
| 72 | − endpoint: String(config.endpoint), | |
| 73 | − schema: config.schema as GraphqlToolSourceConfig["schema"], | |
| 74 | − auth: config.auth as GraphqlToolSourceConfig["auth"], | |
| 75 | − defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"], | |
| 76 | − defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"], | |
| 77 | − overrides: config.overrides as GraphqlToolSourceConfig["overrides"], | |
| 82 | + endpoint: config.endpoint, | |
| 83 | + schema: config.schema, | |
| 84 | + auth: config.auth, | |
| 85 | + defaultQueryApproval: config.defaultQueryApproval, | |
| 86 | + defaultMutationApproval: config.defaultMutationApproval, | |
| 87 | + overrides: config.overrides, | |
| 78 | 88 | }; |
| 79 | − return result; | |
| 89 | + return Result.ok(result); | |
| 90 | + } | |
| 91 | + | |
| 92 | + const configResult = normalizeToolSourceConfig("openapi", raw.config); | |
| 93 | + if (configResult.isErr()) { | |
| 94 | + return Result.err( | |
| 95 | + new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | |
| 96 | + ); | |
| 80 | 97 | } |
| 98 | + const config = configResult.value; | |
| 81 | 99 | |
| 82 | 100 | const result: OpenApiToolSourceConfig = { |
| 83 | 101 | type: "openapi", |
| 84 | 102 | name: raw.name, |
| 85 | 103 | sourceId: raw.id, |
| 86 | 104 | sourceKey: `source:${raw.id}`, |
| 87 | − spec: config.spec as OpenApiToolSourceConfig["spec"], | |
| 88 | − baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], | |
| 89 | − auth: config.auth as OpenApiToolSourceConfig["auth"], | |
| 90 | − defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"], | |
| 91 | − defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"], | |
| 92 | − overrides: config.overrides as OpenApiToolSourceConfig["overrides"], | |
| 105 | + spec: config.spec, | |
| 106 | + collectionUrl: config.collectionUrl, | |
| 107 | + postmanProxyUrl: config.postmanProxyUrl, | |
| 108 | + baseUrl: config.baseUrl, | |
| 109 | + auth: config.auth, | |
| 110 | + defaultReadApproval: config.defaultReadApproval, | |
| 111 | + defaultWriteApproval: config.defaultWriteApproval, | |
| 112 | + overrides: config.overrides, | |
| 93 | 113 | }; |
| 94 | − return result; | |
| 114 | + return Result.ok(result); | |
| 95 | 115 | } |
| 96 | 116 | |
| 97 | 117 | function buildHeadersFromCredentialSecret( |
| @@ -26,6 +26,7 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 25 unmodified lines | ||
| 26 | 26 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 27 | 27 | import { registrySignatureForWorkspace } from "./tool_registry_state"; |
| 28 | 28 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 29 | +import { asPayload } from "../lib/object"; | |
| 29 | 30 | |
| 30 | 31 | const baseTools = new Map<string, ToolDefinition>(); |
| 31 | 32 | |
| @@ -58,7 +59,7 @@ baseTools.set("admin.send_announcement", { · 26 unmodified lines | ||
| 58 | 59 | }, |
| 59 | 60 | }, |
| 60 | 61 | run: async (input: unknown) => { |
| 61 | − const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | |
| 62 | + const payload = asPayload(input); | |
| 62 | 63 | const channel = typeof payload.channel === "string" ? payload.channel : ""; |
| 63 | 64 | const message = typeof payload.message === "string" ? payload.message : ""; |
| 64 | 65 | return { ok: true, channel, message }; |
| @@ -365,7 +366,7 @@ function asJsonSchema(value: unknown): JsonSchema { · 300 unmodified lines | ||
| 365 | 366 | if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 366 | 367 | return {}; |
| 367 | 368 | } |
| 368 | − return value as JsonSchema; | |
| 369 | + return asPayload(value); | |
| 369 | 370 | } |
| 370 | 371 | |
| 371 | 372 | async function buildWorkspaceToolRegistry( |
| @@ -513,7 +514,7 @@ export async function getWorkspaceTools( · 141 unmodified lines | ||
| 513 | 514 | if (source.type !== "mcp") { |
| 514 | 515 | return false; |
| 515 | 516 | } |
| 516 | − const auth = source.config.auth as Record<string, unknown> | undefined; | |
| 517 | + const auth = asPayload(source.config.auth); | |
| 517 | 518 | return auth?.mode === "actor"; |
| 518 | 519 | }); |
| 519 | 520 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; |
| @@ -598,12 +599,12 @@ export async function getWorkspaceTools( · 78 unmodified lines | ||
| 598 | 599 | const warnings: string[] = []; |
| 599 | 600 | const normalizeSourcesStartedAt = Date.now(); |
| 600 | 601 | for (const source of sources) { |
| 601 | − try { | |
| 602 | − configs.push(normalizeExternalToolSource(source)); | |
| 603 | − } catch (error) { | |
| 604 | − const message = error instanceof Error ? error.message : String(error); | |
| 605 | − warnings.push(`Source '${source.name}': ${message}`); | |
| 602 | + const normalizedResult = normalizeExternalToolSource(source); | |
| 603 | + if (normalizedResult.isErr()) { | |
| 604 | + warnings.push(`Source '${source.name}': ${normalizedResult.error.message}`); | |
| 605 | + continue; | |
| 606 | 606 | } |
| 607 | + configs.push(normalizedResult.value); | |
| 607 | 608 | } |
| 608 | 609 | traceStep("normalizeSources", normalizeSourcesStartedAt); |
| 609 | 610 | |