From 49afaf50f1a9db3006df49cb44d433ceb04a7776 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Mon, 14 Sep 2026 05:33:35 +0700 Subject: [PATCH 01/38] feat(mcp): agent-first MCP surface, flow-spec DSL, capability discovery - collapse mcp tool surface from ~385 to 44 default tools via x-mcp openapi extension; search_tools/call_tool meta-tools for the long tail - add flow-spec authoring dsl + compiler (packages/flow-config/authoring) wired into flows.publish/updateDraft/validate - add capabilities.get, schemas.flowSpec, token.get discovery endpoints - add scope- and permission-based tools/list filtering in mcp-server with cached token introspection - fix mcp-server per-request token resolution, cors origin, docker env vars --- .../public-spec-mcp.test.ts.snap | 50 ++ .../public-spec-operations.test.ts.snap | 20 + .../__tests__/capabilities-public-api.test.ts | 144 ++++ .../__tests__/flows-public-api.test.ts | 85 +++ .../builder/__tests__/public-spec-mcp.test.ts | 149 ++++ .../__tests__/public-spec-operations.test.ts | 4 + .../token-introspection-public-api.test.ts | 136 ++++ .../src/features/analytics/api/public.ts | 7 + .../features/automated-response/api/public.ts | 7 + .../src/features/broadcasts/api/public.ts | 9 + .../src/features/capabilities/api/public.ts | 120 +++ .../src/features/contact-filter/api/public.ts | 2 + .../features/contact-sequences/api/public.ts | 4 + .../src/features/contacts/api/public/crud.ts | 18 + .../contacts/api/public/custom-fields.ts | 4 + .../features/contacts/api/public/messages.ts | 7 + .../src/features/contacts/api/public/tags.ts | 4 + .../src/features/conversations/api/public.ts | 13 + .../src/features/custom-fields/api/public.ts | 7 + apps/builder/src/features/flows/api/public.ts | 59 +- .../flows/lib/compile-spec-to-graph.ts | 22 + .../src/features/flows/schema/action.ts | 28 +- .../src/features/inboxes/api/public.ts | 2 + .../src/features/messages/api/public.ts | 7 + .../src/features/sequences/api/public.ts | 7 + apps/builder/src/features/tags/api/public.ts | 6 + apps/builder/src/features/token/api/public.ts | 54 ++ .../src/features/triggers/api/public.ts | 3 + apps/builder/src/lib/orpc/mcp-annotations.ts | 56 ++ apps/builder/src/orpc.ts | 40 +- apps/builder/src/routers/public.ts | 8 + apps/mcp-server/.env.example | 8 + apps/mcp-server/README.md | 186 ++--- apps/mcp-server/SKILL.md | 55 +- apps/mcp-server/__tests__/meta-tools.test.ts | 231 ++++++ .../__tests__/openapi-loader.test.ts | 267 +++++++ apps/mcp-server/__tests__/sse-server.test.ts | 134 ++++ .../__tests__/token-introspection.test.ts | 115 +++ .../rootfs/usr/local/bin/docker-entrypoint.sh | 2 +- apps/mcp-server/src/openapi-loader.ts | 103 +++ .../src/server/create-mcp-server.ts | 151 +--- apps/mcp-server/src/server/execute-tool.ts | 107 +++ apps/mcp-server/src/server/meta-tools.ts | 204 ++++++ apps/mcp-server/src/server/sse-server.ts | 74 +- apps/mcp-server/src/token-introspection.ts | 57 ++ packages/business/package.json | 1 + packages/business/src/capabilities/index.ts | 1 + packages/business/src/capabilities/service.ts | 316 ++++++++ .../__tests__/authoring/compile.test.ts | 512 +++++++++++++ packages/flow-config/src/authoring/compile.ts | 693 ++++++++++++++++++ packages/flow-config/src/authoring/errors.ts | 144 ++++ packages/flow-config/src/authoring/layout.ts | 88 +++ .../flow-config/src/authoring/spec-schema.ts | 388 ++++++++++ packages/flow-config/src/index.ts | 4 + packages/flow-config/src/nodes/index.ts | 2 +- 55 files changed, 4647 insertions(+), 278 deletions(-) create mode 100644 apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap create mode 100644 apps/builder/__tests__/capabilities-public-api.test.ts create mode 100644 apps/builder/__tests__/public-spec-mcp.test.ts create mode 100644 apps/builder/__tests__/token-introspection-public-api.test.ts create mode 100644 apps/builder/src/features/capabilities/api/public.ts create mode 100644 apps/builder/src/features/flows/lib/compile-spec-to-graph.ts create mode 100644 apps/builder/src/features/token/api/public.ts create mode 100644 apps/builder/src/lib/orpc/mcp-annotations.ts create mode 100644 apps/mcp-server/__tests__/meta-tools.test.ts create mode 100644 apps/mcp-server/__tests__/sse-server.test.ts create mode 100644 apps/mcp-server/__tests__/token-introspection.test.ts create mode 100644 apps/mcp-server/src/server/execute-tool.ts create mode 100644 apps/mcp-server/src/server/meta-tools.ts create mode 100644 apps/mcp-server/src/token-introspection.ts create mode 100644 packages/business/src/capabilities/index.ts create mode 100644 packages/business/src/capabilities/service.ts create mode 100644 packages/flow-config/__tests__/authoring/compile.test.ts create mode 100644 packages/flow-config/src/authoring/compile.ts create mode 100644 packages/flow-config/src/authoring/errors.ts create mode 100644 packages/flow-config/src/authoring/layout.ts create mode 100644 packages/flow-config/src/authoring/spec-schema.ts diff --git a/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap new file mode 100644 index 0000000000..26fca37407 --- /dev/null +++ b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`default tool set > operation ids match the curated snapshot 1`] = ` +[ + "analytics.contactsCount", + "analytics.newContactsCount", + "broadcasts.create", + "broadcasts.list", + "broadcasts.schedule", + "broadcasts.stop", + "capabilities.get", + "contacts.addTags", + "contacts.count", + "contacts.create", + "contacts.get", + "contacts.list", + "contacts.listFilterFields", + "contacts.search", + "contacts.sendFlow", + "contacts.sendMessage", + "contacts.setCustomFields", + "contacts.subscribeSequences", + "contacts.update", + "contacts.upsert", + "conversations.archive", + "conversations.assign", + "conversations.get", + "conversations.list", + "customFields.create", + "customFields.list", + "flows.create", + "flows.get", + "flows.list", + "flows.publish", + "flows.validate", + "inboxes.list", + "keywords.create", + "keywords.list", + "messages.create", + "messages.list", + "schemas.flowSpec", + "sequences.create", + "sequences.list", + "tags.create", + "tags.list", + "token.get", + "triggers.create", + "triggers.list", +] +`; diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index 38ddcdf85f..5737983661 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -587,6 +587,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "broadcasts.updateDraft", "path": "/v1/broadcasts/{id}/draft", }, + { + "method": "GET", + "operationId": "capabilities.get", + "path": "/v1/capabilities", + }, { "method": "POST", "operationId": "channels.deliveryStatus", @@ -1182,6 +1187,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "flows.updateDraft", "path": "/v1/flows/{id}/draft", }, + { + "method": "POST", + "operationId": "flows.validate", + "path": "/v1/flows/validate", + }, { "method": "GET", "operationId": "flows.versions", @@ -1697,6 +1707,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "savedReplies.update", "path": "/v1/saved-replies/{id}", }, + { + "method": "GET", + "operationId": "schemas.flowSpec", + "path": "/v1/schemas/flow-spec", + }, { "method": "POST", "operationId": "sequences.create", @@ -1827,6 +1842,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "templateMessages.list", "path": "/v1/template-messages", }, + { + "method": "GET", + "operationId": "token.get", + "path": "/v1/token", + }, { "method": "POST", "operationId": "triggers.create", diff --git a/apps/builder/__tests__/capabilities-public-api.test.ts b/apps/builder/__tests__/capabilities-public-api.test.ts new file mode 100644 index 0000000000..5f49fece5b --- /dev/null +++ b/apps/builder/__tests__/capabilities-public-api.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +vi.mock("@chatbotx.io/business", () => ({ + quotaEnforcementService: {}, + userQuotaService: {}, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class extends Error {}, +})) + +const getCapabilities = vi.fn() +vi.mock("@chatbotx.io/business/capabilities", () => ({ + CAPABILITIES_INCLUDES: [ + "inboxes", + "templates", + "customFields", + "botFields", + "tags", + "aiAgents", + "sequences", + "flows", + "flowSpec", + ], + getCapabilities, +})) + +await import("@/features/capabilities/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the capabilities public router under the contacts scope", () => { + expect(scopeArgAtImport).toBe("contacts") +}) + +describe("GET /v1/capabilities", () => { + const procedure = findProcedure("GET", "/v1/capabilities") + + test("delegates to getCapabilities with the workspace id and no include by default", async () => { + getCapabilities.mockResolvedValueOnce({ tags: [{ id: "1", name: "VIP" }] }) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: {}, + }) + + expect(getCapabilities).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + include: undefined, + }) + expect(result).toEqual({ tags: [{ id: "1", name: "VIP" }] }) + }) + + test("forwards a parsed include list", async () => { + getCapabilities.mockResolvedValueOnce({}) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { include: ["tags", "flows"] }, + }) + + expect(getCapabilities).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + include: ["tags", "flows"], + }) + }) +}) + +describe("GET /v1/schemas/flow-spec", () => { + const procedure = findProcedure("GET", "/v1/schemas/flow-spec") + + test("returns a JSON Schema object describing the flow-spec DSL", async () => { + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: {}, + }) + + // A real JSON Schema for a discriminated union of 8 step kinds — assert + // it is genuinely derived from the zod schema (has the right shape), + // not that it equals some hand-maintained fixture that could drift. + expect(result).toHaveProperty("type") + expect(JSON.stringify(result)).toContain("formatVersion") + expect(JSON.stringify(result)).toContain("steps") + }) +}) diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index c60b0c9901..eae88dd569 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -73,6 +73,17 @@ vi.mock("@chatbotx.io/business/errors", () => ({ new Error(`${field}: ${message}`), })) +const getFlowAuthoringContext = vi.fn(async () => ({ + templatesByName: new Map(), + inboxesByName: new Map(), + tagsByName: new Map(), + customFieldsByName: new Map(), + flowsByName: new Map(), +})) +vi.mock("@chatbotx.io/business/capabilities", () => ({ + getFlowAuthoringContext, +})) + vi.mock("@chatbotx.io/worker-config", () => ({ DefaultJobAction: { runImport: "runImport" }, defaultQueue: { add: vi.fn() }, @@ -280,6 +291,55 @@ describe("POST /v1/flows/{id}/publish", () => { edges: [], }) }) + + test("compiles a { spec } input into nodes/edges before publishing", async () => { + flowVersionService.publish.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + id: "flow-1", + spec: { + formatVersion: 1, + name: "Spec flow", + steps: [{ type: "send", text: "Hello!" }], + }, + }, + }) + + expect(getFlowAuthoringContext).toHaveBeenCalledWith("workspace-1") + expect(flowVersionService.publish).toHaveBeenCalledTimes(1) + const call = flowVersionService.publish.mock.calls[0][0] + expect(call.workspaceId).toBe("workspace-1") + expect(call.flowId).toBe("flow-1") + expect(call.nodes).toHaveLength(1) + expect(call.nodes[0].type).toBe("sendMessage") + expect(call.edges).toEqual([]) + }) +}) + +describe("POST /v1/flows/validate", () => { + const procedure = findProcedure("POST", "/v1/flows/validate") + + test("compiles a spec and returns the graph without persisting anything", async () => { + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + spec: { + formatVersion: 1, + name: "Spec flow", + steps: [{ type: "send", text: "Hello!" }], + }, + }, + }) + + expect(getFlowAuthoringContext).toHaveBeenCalledWith("workspace-1") + expect(result.nodes).toHaveLength(1) + expect(result.nodes[0].type).toBe("sendMessage") + expect(result.edges).toEqual([]) + expect(flowVersionService.publish).not.toHaveBeenCalled() + expect(flowVersionService.updateDraftByFlowId).not.toHaveBeenCalled() + }) }) describe("PUT /v1/flows/{id}/draft", () => { @@ -300,6 +360,31 @@ describe("PUT /v1/flows/{id}/draft", () => { edges: [], }) }) + + test("compiles a { spec } input into nodes/edges before updating the draft", async () => { + flowVersionService.updateDraftByFlowId.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + id: "flow-1", + spec: { + formatVersion: 1, + name: "Spec flow", + steps: [{ type: "send", text: "Hello!" }], + }, + }, + }) + + expect(getFlowAuthoringContext).toHaveBeenCalledWith("workspace-1") + expect(flowVersionService.updateDraftByFlowId).toHaveBeenCalledTimes(1) + const call = flowVersionService.updateDraftByFlowId.mock.calls[0][0] + expect(call.workspaceId).toBe("workspace-1") + expect(call.flowId).toBe("flow-1") + expect(call.nodes).toHaveLength(1) + expect(call.nodes[0].type).toBe("sendMessage") + expect(call.edges).toEqual([]) + }) }) describe("GET /v1/flows/{id}/versions", () => { diff --git a/apps/builder/__tests__/public-spec-mcp.test.ts b/apps/builder/__tests__/public-spec-mcp.test.ts new file mode 100644 index 0000000000..247a860cc2 --- /dev/null +++ b/apps/builder/__tests__/public-spec-mcp.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node + +import { OpenAPIGenerator } from "@orpc/openapi" +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4" +import { beforeAll, describe, expect, test, vi } from "vitest" + +// Same side-effect-free import stubs as public-spec-operations.test.ts — +// `@/routers/public` transitively boots the real db client and better-auth +// stack, neither of which this test (route metadata only) ever calls. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), + workspaceAuthorizedMidddleware: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/client", () => { + const proxy: unknown = new Proxy(() => proxy, { get: () => proxy }) + return { db: proxy } +}) + +type McpOperationMeta = { + visibility?: "default" | "hidden" + alwaysVisible?: boolean + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + scope?: string +} + +type McpSpecOperation = { + operationId: string + method: string + path: string + summary?: string + description?: string + security?: Record[] + "x-mcp"?: McpOperationMeta +} + +// Every operation whose security includes a workspace-token scheme (i.e. +// `isWorkspaceTokenOperation` in `apps/mcp-server/src/openapi-loader.ts` +// would return true for it) must carry `x-mcp.scope` — that's the signal +// `oo.spec`-wrapping `requireTokenScope` in `apps/builder/src/orpc.ts` +// actually ran. A channel-token-only or unauthenticated operation is exempt: +// it never becomes an MCP tool, so it never needs a scope. +const WORKSPACE_TOKEN_SECURITY_SCHEMES = new Set([ + "bearerAuth", + "developerAccessToken", + "tokenInSearchParams", +]) + +function isWorkspaceTokenOperation(operation: McpSpecOperation): boolean { + if (!operation.security) { + return true + } + return operation.security.some((requirement) => + Object.keys(requirement).some((scheme) => + WORKSPACE_TOKEN_SECURITY_SCHEMES.has(scheme), + ), + ) +} + +// Guard against the default set silently growing back toward 346 — a +// deliberate cap, not a tuned performance number. Bump only alongside an +// explicit decision to add a tool to the default set (see the plan's P0.2 +// table), never as a side effect of an unrelated change. +const MAX_DEFAULT_VISIBLE_OPERATIONS = 45 + +let operations: McpSpecOperation[] + +beforeAll(async () => { + const { publicRouter } = await import("@/routers/public") + const { publicSpecGenerateOptions, withChannelApiTokenSecurity } = + await import("@/lib/orpc/public-spec") + + const generator = new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }) + + const spec = withChannelApiTokenSecurity( + await generator.generate( + publicRouter, + publicSpecGenerateOptions("public-spec-mcp.test"), + ), + ) + + operations = [] + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries( + methods as Record, + )) { + const op = operation as McpSpecOperation + if (!op.operationId) { + continue + } + operations.push({ ...op, method: method.toUpperCase(), path }) + } + } + + operations.sort((a, b) => a.operationId.localeCompare(b.operationId)) +}, 120_000) + +describe("x-mcp.scope coverage", () => { + test("every workspace-token operation carries x-mcp.scope", () => { + const missingScope = operations + .filter(isWorkspaceTokenOperation) + .filter((op) => !op["x-mcp"]?.scope) + .map((op) => op.operationId) + + expect(missingScope).toEqual([]) + }) +}) + +describe("default tool set", () => { + const defaultOperations = () => + operations.filter((op) => op["x-mcp"]?.visibility === "default") + + test(`has at most ${MAX_DEFAULT_VISIBLE_OPERATIONS} operations`, () => { + expect(defaultOperations().length).toBeLessThanOrEqual( + MAX_DEFAULT_VISIBLE_OPERATIONS, + ) + }) + + test("contains no DELETE operation", () => { + const deleteDefaults = defaultOperations() + .filter((op) => op.method === "DELETE") + .map((op) => op.operationId) + + expect(deleteDefaults).toEqual([]) + }) + + test("every default operation has a description, not just a summary", () => { + const missingDescription = defaultOperations() + .filter((op) => !op.description) + .map((op) => op.operationId) + + expect(missingDescription).toEqual([]) + }) + + // A diff here means the default surface changed — intentional per P0.2's + // curated table, never a byproduct of an unrelated route edit. Update the + // snapshot only alongside a deliberate addition/removal. + test("operation ids match the curated snapshot", () => { + expect( + defaultOperations() + .map((op) => op.operationId) + .sort(), + ).toMatchSnapshot() + }) +}) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 0d7c70aa37..a0786b3833 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -230,6 +230,10 @@ describe("public API spec — operation naming guard", () => { // `channels.me` legitimately echoes the authenticated token's own // workspace/inbox identity — that IS the endpoint's purpose. "channels.me", + // `token.get` legitimately echoes the calling token's own workspace + // id, permission, and scopes — that IS the endpoint's purpose (P2.3 + // token introspection), same rationale as `channels.me`. + "token.get", // Pre-existing leaks, confirmed present on `main` before the analytics // router this test was strengthened for (verified via a clean diff --git a/apps/builder/__tests__/token-introspection-public-api.test.ts b/apps/builder/__tests__/token-introspection-public-api.test.ts new file mode 100644 index 0000000000..cf79e3d37e --- /dev/null +++ b/apps/builder/__tests__/token-introspection-public-api.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +vi.mock("@chatbotx.io/business", () => ({ + quotaEnforcementService: {}, + userQuotaService: {}, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class extends Error {}, +})) + +await import("@/features/token/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the token public router under the contacts scope", () => { + expect(scopeArgAtImport).toBe("contacts") +}) + +describe("GET /v1/token", () => { + const procedure = findProcedure("GET", "/v1/token") + + test("returns the calling token's workspace id, permission, and scopes", async () => { + const result = await procedure.handler?.({ + context: { + workspace: { id: "workspace-1" }, + apiToken: { + id: "token-1", + workspaceId: "workspace-1", + permission: "full", + scopes: ["contacts", "automation"], + isDefault: false, + }, + }, + }) + + expect(result).toEqual({ + workspaceId: "workspace-1", + permission: "full", + scopes: ["contacts", "automation"], + }) + }) + + test("reports a read_only permission", async () => { + const result = await procedure.handler?.({ + context: { + workspace: { id: "workspace-1" }, + apiToken: { + id: "token-2", + workspaceId: "workspace-1", + permission: "read_only", + scopes: ["contacts"], + isDefault: false, + }, + }, + }) + + expect(result.permission).toBe("read_only") + }) + + test("reports null scopes as unrestricted rather than an empty list", async () => { + const result = await procedure.handler?.({ + context: { + workspace: { id: "workspace-1" }, + apiToken: { + id: "token-3", + workspaceId: "workspace-1", + permission: "full", + scopes: null, + isDefault: true, + }, + }, + }) + + expect(result.scopes).toBeNull() + }) +}) diff --git a/apps/builder/src/features/analytics/api/public.ts b/apps/builder/src/features/analytics/api/public.ts index f53176230f..e0cd233e56 100644 --- a/apps/builder/src/features/analytics/api/public.ts +++ b/apps/builder/src/features/analytics/api/public.ts @@ -13,6 +13,7 @@ import { } from "@chatbotx.io/analytics" import { invalidateCacheByTags, withCache } from "@chatbotx.io/redis" import type { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, @@ -175,7 +176,10 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/new-contacts-count", summary: "Get new contacts count", + description: + "Counts contacts first created within the given `from`/`to` time range.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactsCountPublicResponse) @@ -206,7 +210,10 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/contacts-count", summary: "Get contacts count", + description: + "Counts all contacts that existed at any point within the given `from`/`to` time range.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactsCountPublicResponse) diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index d2ab06b1bb..965e4de938 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -2,6 +2,7 @@ import { automatedResponseService } from "@chatbotx.io/business" import { automatedResponseTypes } from "@chatbotx.io/database/partials" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -21,7 +22,10 @@ export const keywordsPublicRouter = { method: "GET", path: "/v1/keywords", summary: "List keywords (automated responses)", + description: + "Lists keyword-triggered automated responses in the workspace, filterable by `type` (inbound/comment).", tags: ["Keywords"], + spec: mcpSpec({ visibility: "default" }), }) .input( publicListRequest.extend({ @@ -71,8 +75,11 @@ export const keywordsPublicRouter = { method: "POST", path: "/v1/keywords", summary: "Create a keyword automation", + description: + "Creates a keyword automation that replies with text or starts a flow when any of `keywords` is matched in an inbound message or comment.", successStatus: 201, tags: ["Keywords"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index f9be0f651c..987b2b9fe4 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -3,6 +3,7 @@ import { notFoundException } from "@chatbotx.io/business/errors" import { broadcastStatuses } from "@chatbotx.io/database/partials" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -55,7 +56,10 @@ export const broadcastsPublicRouter = { method: "GET", path: "/v1/broadcasts", summary: "Get all broadcasts", + description: + "Lists broadcasts in the workspace across every status (draft, scheduled, sending, sent, cancelled), newest first.", tags: ["Broadcasts"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListBroadcastsResponse) @@ -148,8 +152,11 @@ export const broadcastsPublicRouter = { method: "POST", path: "/v1/broadcasts", summary: "Create a broadcast", + description: + "Creates a broadcast as a draft (or immediately scheduled, depending on the payload) targeting the given audience filter.", successStatus: 201, tags: ["Broadcasts"], + spec: mcpSpec({ visibility: "default" }), }) .input(createBroadcastRequest) .output(publicBroadcastResource) @@ -218,6 +225,7 @@ export const broadcastsPublicRouter = { summary: "Schedule a draft broadcast", description: "Only matches a broadcast whose status is draft.", tags: ["Broadcasts"], + spec: mcpSpec({ visibility: "default" }), }) .input(scheduleBroadcastSchema.and(z.object({ id: zodBigintAsString() }))) .output(z.object({ id: z.string() })) @@ -258,6 +266,7 @@ export const broadcastsPublicRouter = { summary: "Stop a broadcast that is currently sending", description: "Only matches a broadcast whose status is sending.", tags: ["Broadcasts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ id: zodBigintAsString() })) .output(z.object({ id: z.string() })) diff --git a/apps/builder/src/features/capabilities/api/public.ts b/apps/builder/src/features/capabilities/api/public.ts new file mode 100644 index 0000000000..e6ccc6d52c --- /dev/null +++ b/apps/builder/src/features/capabilities/api/public.ts @@ -0,0 +1,120 @@ +import { + CAPABILITIES_INCLUDES, + getCapabilities, +} from "@chatbotx.io/business/capabilities" +import { flowSpecSchema } from "@chatbotx.io/flow-config" +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4" +import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" +import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" + +// Endpoint discovery, not a resource read — reuses the `contacts` scope +// exactly like `GET /v1/contacts/filter-fields` (see +// `features/contact-filter/api/public.ts`): the most common scope, and this +// only ever returns metadata (ids/names), never contact data. `alwaysVisible` +// (P2.3) exempts it from scope-based `tools/list` filtering so a token +// missing `contacts` still sees this tool and its 403, instead of the tool +// disappearing without a trace. +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") + +const namedEntityResponse = z.object({ id: z.string(), name: z.string() }) +const fieldResponse = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), +}) + +const capabilitiesPublicResponse = z.object({ + inboxes: z + .array(z.object({ id: z.string(), name: z.string(), channel: z.string() })) + .optional(), + templates: z + .array( + z.object({ + id: z.string(), + name: z.string(), + language: z.string(), + status: z.string(), + params: z.unknown(), + }), + ) + .optional(), + customFields: z.array(fieldResponse).optional(), + botFields: z.array(fieldResponse).optional(), + tags: z.array(namedEntityResponse).optional(), + aiAgents: z.array(namedEntityResponse).optional(), + sequences: z.array(namedEntityResponse).optional(), + flows: z.array(namedEntityResponse).optional(), + flowSpec: z + .object({ + stepTypes: z.array( + z.object({ type: z.string(), description: z.string() }), + ), + waitUnits: z.array(z.string()), + channels: z.array(z.string()), + }) + .optional(), +}) + +const includeQueryParam = z.preprocess((value) => { + if (typeof value !== "string") { + return value + } + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + return parts.length > 0 ? parts : undefined +}, z.array(z.enum(CAPABILITIES_INCLUDES)).optional()) + +const flowSpecJsonSchemaConverter = new ZodToJsonSchemaConverter() + +export const capabilitiesPublicRouter = { + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/capabilities", + summary: + "Discover the workspace's inboxes, templates, fields, tags, sequences, and flows", + description: + "Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.create`/`flows.publish` so names in a flow spec resolve to real ids instead of guesses.", + tags: ["Capabilities"], + spec: mcpSpec({ visibility: "default", alwaysVisible: true }), + }) + .input(z.object({ include: includeQueryParam })) + .output(capabilitiesPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler( + async ({ context, input }) => + await getCapabilities({ + workspaceId: context.workspace.id, + include: input.include, + }), + ), +} + +export const schemasPublicRouter = { + flowSpec: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/schemas/flow-spec", + summary: "Get the JSON Schema for the flow-spec DSL", + description: + "Returns the JSON Schema for the `spec` object accepted by `flows.publish`'s `{ spec }` input and `flows.validate` — the authoritative reference for every step type's fields. Use `capabilities.get` first to resolve the names (templates, flows, tags, custom fields) a spec references into real ids.", + tags: ["Capabilities"], + spec: mcpSpec({ visibility: "default" }), + }) + .input(z.object({})) + .output(z.record(z.string(), z.unknown())) + .errors(possibleErrorsOnListingResource) + .handler(() => { + const [, jsonSchema] = flowSpecJsonSchemaConverter.convert( + flowSpecSchema, + { + strategy: "input", + }, + ) + return jsonSchema as Record + }), +} diff --git a/apps/builder/src/features/contact-filter/api/public.ts b/apps/builder/src/features/contact-filter/api/public.ts index da30710594..74b78de9a3 100644 --- a/apps/builder/src/features/contact-filter/api/public.ts +++ b/apps/builder/src/features/contact-filter/api/public.ts @@ -1,5 +1,6 @@ import { listContactFilterFieldsForAPI } from "@/features/contact-filter/lib/list-contact-filter-fields" import { listContactFilterFieldsPublicResponse } from "@/features/contact-filter/schema/public" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" import { workspaceTokenAuthAPIForScope } from "@/orpc" @@ -14,6 +15,7 @@ export const contactsFilterFieldsPublicRouter = { description: "Returns the static fields available for `contactFilter` conditions (with each field's supported operators), plus the workspace's actual custom fields, bot fields, and tags so a filter condition can reference a real id/name instead of guessing one. Use this before building a `contactFilter` for `contacts.search` or `contacts.count`.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .output(listContactFilterFieldsPublicResponse) .errors(possibleErrorsOnListingResource) diff --git a/apps/builder/src/features/contact-sequences/api/public.ts b/apps/builder/src/features/contact-sequences/api/public.ts index 46e007a880..9bd381d66a 100644 --- a/apps/builder/src/features/contact-sequences/api/public.ts +++ b/apps/builder/src/features/contact-sequences/api/public.ts @@ -6,6 +6,7 @@ import { listContactSequencesPublicResponse, setContactSequencesPublicRequest, } from "@/features/contact-sequences/schema/public" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, @@ -44,8 +45,11 @@ export const contactsSequencesPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/sequences", summary: "Enroll the contact in one or more sequences", + description: + "Adds the contact identified by `identifier` to each given sequence; sequences the contact is already enrolled in are left as-is. Use `sequences.list`/`sequences.create` first to resolve names to ids.", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( contactSequenceIdsPublicRequest.and( diff --git a/apps/builder/src/features/contacts/api/public/crud.ts b/apps/builder/src/features/contacts/api/public/crud.ts index 515c539019..4fd8e40f07 100644 --- a/apps/builder/src/features/contacts/api/public/crud.ts +++ b/apps/builder/src/features/contacts/api/public/crud.ts @@ -1,6 +1,7 @@ import { contactService, importService, UNSCOPED } from "@chatbotx.io/business" import { contactSources, genderTypes } from "@chatbotx.io/database/partials" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnFindingResource, @@ -40,6 +41,7 @@ export const contactsCrudPublicRouter = { description: "List contacts in the workspace, with optional keyword search and filter. Supports `include` to shrink the response (e.g. `include=tags`) and `withCount=false` to skip the total-count query when you only need the rows.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(listContactsPublicRequest) .output(listContactsResponse) @@ -63,6 +65,7 @@ export const contactsCrudPublicRouter = { description: "Same as `GET /v1/contacts` but accepts the filter as a JSON request body instead of query parameters — use this when `contactFilter` is large or deeply nested. Supports the same `include`/`withCount` options.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(listContactsPublicRequest) .output(listContactsResponse) @@ -83,7 +86,10 @@ export const contactsCrudPublicRouter = { method: "GET", path: "/v1/contacts/count", summary: "Count contacts matching a filter", + description: + "Counts contacts matching the same filter shape as `contacts.list`/`contacts.search`, without paginating the rows.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(countContactsPublicRequest) .output(countContactsPublicResponse) @@ -103,7 +109,10 @@ export const contactsCrudPublicRouter = { path: "/v1/contacts/{identifier}", summary: "Get contact by identifier (id:123, email:user@example.com, phone:+84...)", + description: + "Looks up a single contact by a prefixed identifier: `id:`, `email:
`, or `phone:`.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ identifier: z.string().min(1) })) .output(contactResponse) @@ -124,7 +133,10 @@ export const contactsCrudPublicRouter = { method: "POST", path: "/v1/contacts", summary: "Create a contact", + description: + "Creates a new contact directly in the workspace (not via a channel conversation). At least one of email or phoneNumber is typically required for later messaging.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(createContactRequest) .output(contactResponse) @@ -187,8 +199,11 @@ export const contactsCrudPublicRouter = { method: "PUT", path: "/v1/contacts/{identifier}", summary: "Update contact fields", + description: + "Overwrites the given standard and/or custom fields on the contact identified by `identifier`; fields omitted from the body are left unchanged.", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z @@ -277,7 +292,10 @@ export const contactsCrudPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/upsert", summary: "Upsert a contact by identifier", + description: + "Creates the contact identified by `identifier` if it doesn't exist yet, otherwise updates the given fields on the existing one.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/custom-fields.ts b/apps/builder/src/features/contacts/api/public/custom-fields.ts index 07de29a15b..b57e6dd505 100644 --- a/apps/builder/src/features/contacts/api/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/api/public/custom-fields.ts @@ -4,6 +4,7 @@ import { } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, @@ -107,8 +108,11 @@ export const contactsCustomFieldsPublicRouter = { method: "PUT", path: "/v1/contacts/{identifier}/custom-fields", summary: "Set multiple custom field values for a contact", + description: + "Sets each given custom field to its value on the contact identified by `identifier`; fields not listed are left unchanged. Use `customFields.list`/`customFields.create` first to resolve names to ids.", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/messages.ts b/apps/builder/src/features/contacts/api/public/messages.ts index 2ee36bd4a6..70d8525b81 100644 --- a/apps/builder/src/features/contacts/api/public/messages.ts +++ b/apps/builder/src/features/contacts/api/public/messages.ts @@ -11,6 +11,7 @@ import { listMessages } from "@/features/messages/queries" import { createMessageRequest } from "@/features/messages/schema/mutation" import { listMessagesResponse } from "@/features/messages/schema/query" import { messageResourceWithRelations } from "@/features/messages/schema/resource" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnFindingResource, possibleErrorsOnMutatingResource, @@ -30,8 +31,11 @@ export const contactsMessagesPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/messages", summary: "Send message to contact", + description: + "Sends a text/media message to the contact identified by `identifier` on their existing conversation, creating one if none exists yet. Requires the contact to have an inbox they can be reached on (see `inboxes.list`).", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( createMessageRequest.and( @@ -182,8 +186,11 @@ export const contactsMessagesPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/flows", summary: "Send flow to contact", + description: + "Starts the given flow for the contact identified by `identifier`, delivering its first message on their existing (or newly created) conversation.", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/tags.ts b/apps/builder/src/features/contacts/api/public/tags.ts index b691b8ae89..d1334da7f1 100644 --- a/apps/builder/src/features/contacts/api/public/tags.ts +++ b/apps/builder/src/features/contacts/api/public/tags.ts @@ -2,6 +2,7 @@ import { contactService, tagService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" import { publicTagResource } from "@/features/tags/schema/resource" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, @@ -43,8 +44,11 @@ export const contactsTagsPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/tags", summary: "Add tags to the contact", + description: + "Attaches the given tag ids to the contact identified by `identifier`; tags already on the contact are left as-is. Use `tags.list`/`tags.create` first to resolve names to ids.", successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/conversations/api/public.ts b/apps/builder/src/features/conversations/api/public.ts index 1e97ea02fb..6782371d28 100644 --- a/apps/builder/src/features/conversations/api/public.ts +++ b/apps/builder/src/features/conversations/api/public.ts @@ -7,6 +7,7 @@ import { import z from "zod" import { successResponse } from "@/features/common/schema" import { contactFilterCriteriaSchema } from "@/features/contact-filter" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnFindingResource, possibleErrorsOnListingResource, @@ -73,7 +74,10 @@ export const conversationsPublicRouter = { method: "GET", path: "/v1/conversations", summary: "List conversations", + description: + "Lists conversations in the workspace with optional filters (status, channel, assignee, tags, contact filter). Use `conversations.get` for the full detail of one.", tags: ["Conversations"], + spec: mcpSpec({ visibility: "default" }), }) .input(listConversationsQueryRequest) .output(listConversationsResponse) @@ -96,7 +100,10 @@ export const conversationsPublicRouter = { method: "GET", path: "/v1/conversations/{id}", summary: "Get a conversation by id", + description: + "Returns the full detail of a single conversation, including its contact, channel, assignee, and status.", tags: ["Conversations"], + spec: mcpSpec({ visibility: "default" }), }) .input(conversationIdPathParam) .output(getConversationPublicResponse) @@ -119,7 +126,10 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/assign", summary: "Assign or unassign a conversation to a user or inbox team", + description: + "Sets the conversation's assignee. Pass a user id, an inbox team id, or `null`/omit `assignedId` to unassign.", tags: ["Conversations"], + spec: mcpSpec({ visibility: "default" }), }) .input(assignConversationPublicRequest.and(conversationIdPathParam)) .output(successResponse) @@ -148,7 +158,10 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/archive", summary: "Archive a conversation", + description: + "Archives the conversation, removing it from the default inbox view. Use `conversations.list` with the appropriate filter to find archived conversations again.", tags: ["Conversations"], + spec: mcpSpec({ visibility: "default" }), }) .input(conversationIdPathParam) .output(successResponse) diff --git a/apps/builder/src/features/custom-fields/api/public.ts b/apps/builder/src/features/custom-fields/api/public.ts index 22fdd155bc..61fbfa0608 100644 --- a/apps/builder/src/features/custom-fields/api/public.ts +++ b/apps/builder/src/features/custom-fields/api/public.ts @@ -1,6 +1,7 @@ import { customFieldService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -25,7 +26,10 @@ export const customFieldsPublicRouter = { method: "GET", path: "/v1/custom-fields", summary: "Get all custom fields", + description: + "Lists every custom field defined in the workspace, with its id and type. Use `contacts.setCustomFields` to set values on a contact.", tags: ["Custom Fields"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(listPublicCustomFieldsResponse) @@ -43,8 +47,11 @@ export const customFieldsPublicRouter = { method: "POST", path: "/v1/custom-fields", summary: "Create a custom field", + description: + "Defines a new custom field on the workspace with the given name and value type.", successStatus: 201, tags: ["Custom Fields"], + spec: mcpSpec({ visibility: "default" }), }) .input(createCustomFieldRequest.pick({ name: true, type: true })) .output(publicCustomFieldResource) diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index ee77fc060c..a1d5eb64ad 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -8,6 +8,7 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { DefaultJobAction, defaultQueue } from "@chatbotx.io/worker-config" import { z } from "zod" import { flowVersionResource } from "@/features/flow-versions/schema/resource" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -17,10 +18,13 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { compileSpecToGraph } from "../lib/compile-spec-to-graph" import { createFlowSchema, + flowSpecRequest, + publishFlowRequest, publishFlowSchema, - updateDraftFlowVersionSchema, + updateDraftFlowRequest, updateFlowSchema, } from "../schema/action" import { flowResource, flowWithVersionsResource } from "../schema/resource" @@ -35,6 +39,7 @@ export const flowsPublicRouter = { summary: "List flows", description: "Lists active flows in the workspace.", tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) .input( publicListRequest.extend({ @@ -61,6 +66,7 @@ export const flowsPublicRouter = { summary: "Get a flow by id", description: "Returns a flow with its list of versions.", tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ id: zodBigintAsString() })) .output(flowWithVersionsResource) @@ -82,6 +88,7 @@ export const flowsPublicRouter = { "Creates a new draft flow seeded with a single default start node.", successStatus: 201, tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) .input(createFlowSchema) .output(z.object({ id: z.string() })) @@ -153,38 +160,68 @@ export const flowsPublicRouter = { path: "/v1/flows/{id}/publish", summary: "Publish a flow", description: - "Publishes the given nodes/edges as a new immutable version and syncs the draft to match.", + "Publishes a new immutable version and syncs the draft to match. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` — a flow-spec DSL object (see `GET /v1/schemas/flow-spec`) compiled server-side into that same graph before publishing.", tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) - .input(publishFlowSchema.and(z.object({ id: zodBigintAsString() }))) + .input(publishFlowRequest.and(z.object({ id: zodBigintAsString() }))) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { - const { id, nodes, edges } = input + const { id } = input + const workspaceId = context.workspace.id + const { nodes, edges } = + "spec" in input + ? publishFlowSchema.parse( + await compileSpecToGraph(input.spec, workspaceId), + ) + : input await flowVersionService.publish({ - workspaceId: context.workspace.id, + workspaceId, flowId: id, nodes, edges, }) }), + validate: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/flows/validate", + summary: "Compile and validate a flow spec without publishing it", + description: + "Compiles a flow-spec DSL object (see `GET /v1/schemas/flow-spec`) and validates the result exactly like `flows.publish` would, without persisting anything. On success, returns the compiled node/edge graph. On failure, returns a 422 with structured errors (`path`/`code`/`message`/`hint`/`candidates`) — fix and retry before calling `flows.publish`.", + tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), + }) + .input(flowSpecRequest) + .output(publishFlowSchema) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => + publishFlowSchema.parse( + await compileSpecToGraph(input.spec, context.workspace.id), + ), + ), + updateDraft: workspaceTokenAuthAPI .route({ method: "PUT", path: "/v1/flows/{id}/draft", summary: "Update a flow's draft version", description: - "Overwrites the draft version's nodes/edges in place, without publishing.", + "Overwrites the draft version's nodes/edges in place, without publishing. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` compiled server-side into that same graph — draft nodes are not otherwise validated (see `flows.validate` to check a spec before writing it).", tags: ["Flows"], }) - .input( - updateDraftFlowVersionSchema.and(z.object({ id: zodBigintAsString() })), - ) + .input(updateDraftFlowRequest.and(z.object({ id: zodBigintAsString() }))) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { - const { id, nodes, edges } = input + const { id } = input + const workspaceId = context.workspace.id + const { nodes, edges } = + "spec" in input + ? await compileSpecToGraph(input.spec, workspaceId) + : input await flowVersionService.updateDraftByFlowId({ - workspaceId: context.workspace.id, + workspaceId, flowId: id, nodes, edges, diff --git a/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts new file mode 100644 index 0000000000..44bdf4c3b2 --- /dev/null +++ b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts @@ -0,0 +1,22 @@ +import { getFlowAuthoringContext } from "@chatbotx.io/business/capabilities" +import { + compileFlowSpec, + type EdgeSchema, + type FlowSpec, + type FlowVersionSchema, +} from "@chatbotx.io/flow-config" + +/** + * Resolves a `{ spec }` flow-authoring request into the raw `{ nodes, edges }` + * graph shape `flowVersionService` persists — the single place + * `flows.publish`/`flows.updateDraft`/`flows.validate` all go through so the + * capabilities lookup and compiler call never drift between them. + */ +export async function compileSpecToGraph( + spec: FlowSpec, + workspaceId: string, +): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> { + const ctx = await getFlowAuthoringContext(workspaceId) + const { nodes, edges } = compileFlowSpec(spec, ctx) + return { nodes, edges } +} diff --git a/apps/builder/src/features/flows/schema/action.ts b/apps/builder/src/features/flows/schema/action.ts index a596ff7088..a7bbd8e0f4 100644 --- a/apps/builder/src/features/flows/schema/action.ts +++ b/apps/builder/src/features/flows/schema/action.ts @@ -1,5 +1,7 @@ import { edgeSchema, + type FlowSpec, + flowSpecSchema, flowVersionSchema, refineStepsByChannel, } from "@chatbotx.io/flow-config" @@ -19,23 +21,45 @@ export const updateFlowSchema = z.object({ }) export type UpdateFlowSchema = z.infer -export const updateDraftFlowVersionSchema = z.object({ +const updateDraftFlowVersionNodesSchema = z.object({ nodes: z.array(z.any()), edges: z.array(edgeSchema), }) +export const updateDraftFlowVersionSchema = updateDraftFlowVersionNodesSchema export type UpdateDraftFlowVersionSchema = z.infer< typeof updateDraftFlowVersionSchema > +/** `{ spec }` input, accepted by `flows.publish`/`flows.updateDraft` alongside the raw `{ nodes, edges }` shape, and the sole input of `flows.validate`. */ +export const flowSpecRequest = z.object({ + spec: flowSpecSchema satisfies z.ZodType, +}) +export type FlowSpecRequest = z.infer + +/** Draft update accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored. */ +export const updateDraftFlowRequest = z.union([ + updateDraftFlowVersionNodesSchema, + flowSpecRequest, +]) +export type UpdateDraftFlowRequest = z.infer + // Channel rules are declared per step (see // `@chatbotx.io/flow-config/channel-rules`), so this stays one generic hook // instead of accumulating a refinement per channel/step pair. -export const publishFlowSchema = z.object({ +const publishFlowNodesSchema = z.object({ nodes: z.array(flowVersionSchema).superRefine(refineStepsByChannel), edges: z.array(edgeSchema), }) +export const publishFlowSchema = publishFlowNodesSchema export type PublishFlowSchema = z.infer +/** Publish accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored — compiled server-side into the same graph shape before publishing. */ +export const publishFlowRequest = z.union([ + publishFlowNodesSchema, + flowSpecRequest, +]) +export type PublishFlowRequest = z.infer + // Reuse the package-level node union so client-side publish validation can // never drift from the server-side `publishFlowSchema` when node types are added. export const updateFlowVersionSchema = publishFlowSchema diff --git a/apps/builder/src/features/inboxes/api/public.ts b/apps/builder/src/features/inboxes/api/public.ts index 2726e57af7..96cd005eff 100644 --- a/apps/builder/src/features/inboxes/api/public.ts +++ b/apps/builder/src/features/inboxes/api/public.ts @@ -1,3 +1,4 @@ +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { listInboxes } from "../queries" @@ -18,6 +19,7 @@ export const inboxesPublicRouter = { description: "List connected inboxes with their internal IDs. Use `id` as the `inboxId` parameter when sending messages or flows to a contact.", tags: ["Channels"], + spec: mcpSpec({ visibility: "default" }), }) .input(publishInboxesRequest) .output(publicListInboxResponse) diff --git a/apps/builder/src/features/messages/api/public.ts b/apps/builder/src/features/messages/api/public.ts index 69ae786a63..c6a5ec02f7 100644 --- a/apps/builder/src/features/messages/api/public.ts +++ b/apps/builder/src/features/messages/api/public.ts @@ -12,6 +12,7 @@ import { } from "@/features/messages/schema/mutation" import { listMessagesResponse } from "@/features/messages/schema/query" import { messageResourceWithRelations } from "@/features/messages/schema/resource" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -50,7 +51,10 @@ export const messagesPublicRouter = { method: "GET", path: "/v1/conversations/{conversationId}/messages", summary: "List messages on a conversation", + description: + "Lists messages on the given conversation, newest-related pagination via `cursor`. Use `conversations.get` first if you only have a contact identifier.", tags: ["Messages"], + spec: mcpSpec({ visibility: "default" }), }) .input(listConversationMessagesPublicRequest) .output(listMessagesResponse) @@ -106,8 +110,11 @@ export const messagesPublicRouter = { method: "POST", path: "/v1/conversations/{conversationId}/messages", summary: "Send a message on a conversation", + description: + "Sends an outgoing text/media message on an existing conversation. To message a contact without first resolving their conversation id, use `contacts.sendMessage` instead.", successStatus: 201, tags: ["Messages"], + spec: mcpSpec({ visibility: "default" }), }) .input(createMessageRequest.and(conversationIdPathParam)) .output(messageResourceWithRelations.nullable()) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 7b09490f8a..a992bb15b4 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -1,6 +1,7 @@ import { sequenceService } from "@chatbotx.io/business/sequence" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -30,7 +31,10 @@ export const sequencesPublicRouter = { method: "GET", path: "/v1/sequences", summary: "List sequences", + description: + "Lists sequences in the workspace. Use `sequences.get`/`contacts.subscribeSequences` next to inspect steps or enroll a contact.", tags: ["Sequences"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(listSequencesResponse) @@ -66,8 +70,11 @@ export const sequencesPublicRouter = { method: "POST", path: "/v1/sequences", summary: "Create a sequence", + description: + "Creates an empty sequence. Add steps afterward via the builder UI or `sequences.upsertStep`.", successStatus: 201, tags: ["Sequences"], + spec: mcpSpec({ visibility: "default" }), }) .input(createSequenceRequest) .output(z.object({ sequenceId: z.string() })) diff --git a/apps/builder/src/features/tags/api/public.ts b/apps/builder/src/features/tags/api/public.ts index a94fdf104a..facc054497 100644 --- a/apps/builder/src/features/tags/api/public.ts +++ b/apps/builder/src/features/tags/api/public.ts @@ -1,6 +1,7 @@ import { tagService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -22,7 +23,10 @@ export const tagsPublicRouter = { method: "GET", path: "/v1/tags", summary: "Get all tags", + description: + "Lists every tag in the workspace. Use `tags.create` to add one, or `contacts.addTags` to attach existing ones to a contact.", tags: ["Tags"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListTagsResponse) @@ -41,8 +45,10 @@ export const tagsPublicRouter = { method: "POST", path: "/v1/tags", summary: "Create a new tag", + description: "Creates a new tag in the workspace, returned with its id.", successStatus: 201, tags: ["Tags"], + spec: mcpSpec({ visibility: "default" }), }) .input(createTagRequest.pick({ name: true })) .output(publicTagResource) diff --git a/apps/builder/src/features/token/api/public.ts b/apps/builder/src/features/token/api/public.ts new file mode 100644 index 0000000000..bb8e345fcf --- /dev/null +++ b/apps/builder/src/features/token/api/public.ts @@ -0,0 +1,54 @@ +import { + type WorkspaceApiTokenPermission, + workspaceApiTokenScopes, +} from "@chatbotx.io/database/partials" +import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" +import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import type { RequestApiToken } from "@/middlewares/context" +import { workspaceTokenAuthAPIForScope } from "@/orpc" + +// Same scope and rationale as `capabilities.get` +// (`features/capabilities/api/public.ts`): endpoint discovery, not a +// resource read, so it reuses the most common scope rather than adding a +// 13th one just for this. `alwaysVisible` (P2.3) exempts it from +// scope-based `tools/list` filtering — a token missing `contacts` still +// sees this tool, whose whole job is telling it exactly that. +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") + +const tokenPublicResponse = z.object({ + workspaceId: z.string(), + permission: z.enum(["read_only", "full"]), + scopes: z + .array(workspaceApiTokenScopes) + .nullable() + .describe("null means unrestricted — every scope."), +}) + +export const tokenPublicRouter = { + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/token", + summary: "Get the calling token's workspace id, permission, and scopes", + description: + "Returns the workspace id, permission (`read_only`/`full`), and scopes of the token making this request. `scopes: null` means unrestricted (every scope). Check this before attempting a write to see whether the token is allowed to make it.", + tags: ["Capabilities"], + spec: mcpSpec({ visibility: "default", alwaysVisible: true }), + }) + .input(z.object({})) + .output(tokenPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(({ context }) => { + // `apiToken` is always set here in practice — this route is built + // from `workspaceTokenAuthAPIForScope`, which always chains + // `workspaceTokenAuthMidddleware` first (see the identical note on + // `requireTokenScope` in `@/orpc`). + const apiToken = context.apiToken as RequestApiToken + return { + workspaceId: context.workspace.id, + permission: apiToken.permission as WorkspaceApiTokenPermission, + scopes: apiToken.scopes, + } + }), +} diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 8433174402..0d3e7ea9a1 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -4,6 +4,7 @@ import { folderTypes } from "@chatbotx.io/database/partials" import type { TriggerModel } from "@chatbotx.io/database/types" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -41,6 +42,7 @@ export const triggersPublicRouter = { summary: "List triggers", description: "Lists triggers with their real conditions and actions.", tags: ["Triggers"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListResponse(triggerResource)) @@ -85,6 +87,7 @@ export const triggersPublicRouter = { "Creates an empty trigger. Use PUT /v1/triggers/{id} to attach conditions and actions.", successStatus: 201, tags: ["Triggers"], + spec: mcpSpec({ visibility: "default" }), }) .input(createTriggerSchema) .output(triggerResource) diff --git a/apps/builder/src/lib/orpc/mcp-annotations.ts b/apps/builder/src/lib/orpc/mcp-annotations.ts new file mode 100644 index 0000000000..e7cb3ad643 --- /dev/null +++ b/apps/builder/src/lib/orpc/mcp-annotations.ts @@ -0,0 +1,56 @@ +import type { OpenAPI } from "@orpc/openapi" + +/** + * `"default"` = shipped in `tools/list` on every MCP connection. `"hidden"` + * (or the field absent — see `mcpSpec` below) = reachable only through the + * `search_tools` / `call_tool` meta-tools. We cannot ask every one of the + * ~346 public operations to opt out individually, so the polarity is + * inverted: opt IN to `"default"` on the ~40 that should always be visible. + */ +export type McpVisibility = "default" | "hidden" + +export type McpRouteMeta = { + /** Absent ⇒ treated as `"hidden"` by the mcp-server loader. */ + visibility?: McpVisibility + /** + * Exempts this operation from scope-based `tools/list` filtering (P2.3) — + * reserved for the small set of discovery endpoints (`capabilities.get`, + * `token.get`) that a token must be able to *see* even when it lacks the + * scope those endpoints themselves require, so the 403 body is visible to + * the agent instead of the tool disappearing without a trace. + */ + alwaysVisible?: boolean + /** Absent ⇒ the mcp-server loader infers this from the HTTP method. */ + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean +} + +/** + * The `@orpc/contract` route-spec type (`OpenAPI.OperationObject`, aliased + * from `openapi-types`' `OpenAPIV3_1.OperationObject`) has no vendor + * extension field, so `x-mcp` is added here rather than via module + * augmentation (this repo's lint forbids `namespace`, the mechanism TS + * declaration merging needs for a third-party namespaced interface). + */ +export type OperationObjectWithMcp = OpenAPI.OperationObject & { + "x-mcp"?: McpRouteMeta & { scope?: string } +} + +/** + * Route-level opt-in used as `.route({ spec: mcpSpec({ visibility: "default" }) })`. + * + * Must be the function form of `spec`, never a plain object — the OpenAPI + * generator treats an object `spec` as a full replacement of the generated + * operation (dropping `parameters`/`requestBody`/`responses`), while the + * function form merges into what was already generated. + */ +export const mcpSpec = + (meta: McpRouteMeta) => + (current: OpenAPI.OperationObject): OperationObjectWithMcp => { + const existing = (current as OperationObjectWithMcp)["x-mcp"] + return { + ...current, + "x-mcp": { ...existing, ...meta }, + } + } diff --git a/apps/builder/src/orpc.ts b/apps/builder/src/orpc.ts index 204377c7d8..9ec92a91ce 100644 --- a/apps/builder/src/orpc.ts +++ b/apps/builder/src/orpc.ts @@ -4,10 +4,13 @@ import { } from "@chatbotx.io/business/errors" import { ModelNotfoundException } from "@chatbotx.io/database/errors" import type { WorkspaceApiTokenScope } from "@chatbotx.io/database/partials" +import { FlowAuthoringException } from "@chatbotx.io/flow-config" import { SdkException } from "@chatbotx.io/sdk" +import { oo } from "@orpc/openapi" import { ORPCError, onError, ValidationError } from "@orpc/server" import { ActionValidationError } from "next-safe-action" import { logger } from "./lib/log" +import type { OperationObjectWithMcp } from "./lib/orpc/mcp-annotations" import { commonApiErrors } from "./lib/orpc/orpc-error-helper" import { authMiddleware } from "./middlewares/auth" import { channelApiTokenAuthMidddleware } from "./middlewares/channel-api-token-auth" @@ -76,6 +79,19 @@ function toKnownOrpcError( }) } + // `compileFlowSpec` (`@chatbotx.io/flow-config`) rejects an agent-authored + // flow spec with every error it found (unknown template/flow names, an + // unreachable step, a bad `goto` target, ...) — same 422 shape as + // `ActionValidationError` above so a caller has one error contract to + // handle, not two. + if (error instanceof FlowAuthoringException) { + return new ORPCError("invalidRequestData", { + message: error.message, + status: 422, + data: error.errors, + }) + } + // oRPC's own input-schema parsing throws a raw ORPCError("BAD_REQUEST", // { cause: ValidationError }) before the handler runs, which would // otherwise bypass this mapper entirely and surface as a 400. Remap it to @@ -149,12 +165,26 @@ const requireTokenScope = (scope: WorkspaceApiTokenScope) => /** * Every workspace-token endpoint must declare its resource scope — there is - * no unscoped variant. This is deliberate: removing a bare - * `workspaceTokenAuthAPI` export makes every current and future endpoint - * fail to compile until it picks a scope, turning the compile error itself - * into the router-sweep checklist. + * no default. `oo.spec` proxies `requireTokenScope(scope)` so the OpenAPI + * generator's `applyCustomOpenAPIOperation` (which walks + * `contract["~orpc"].middlewares`) stamps `x-mcp.scope` on every operation + * that chains through this middleware — one edit here instead of touching + * every one of the ~450 scoped route files. By the time this extender runs, + * `current` already reflects the route's own `.route({ spec: mcpSpec(...) })` + * (applied earlier, during operation generation), so spreading + * `current["x-mcp"]` before writing `scope` keeps a route's declared + * `visibility` while `scope` always comes from this middleware — a route + * can never spoof its own scope. */ export const workspaceTokenAuthAPIForScope = (scope: WorkspaceApiTokenScope) => - publicAPI.use(workspaceTokenAuthMidddleware).use(requireTokenScope(scope)) + publicAPI.use(workspaceTokenAuthMidddleware).use( + oo.spec(requireTokenScope(scope), (current): OperationObjectWithMcp => { + const existing = (current as OperationObjectWithMcp)["x-mcp"] + return { + ...current, + "x-mcp": { ...existing, scope }, + } + }), + ) export const channelApiTokenAPI = publicAPI.use(channelApiTokenAuthMidddleware) diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index c3dc98be0b..687f4312c5 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -11,6 +11,10 @@ import { appointmentsPublicRouter } from "@/features/appointments/api/public" import { keywordsPublicRouter } from "@/features/automated-response/api/public" import { botFieldsPublicRouter } from "@/features/bot-fields/api/public" import { broadcastsPublicRouter } from "@/features/broadcasts/api/public" +import { + capabilitiesPublicRouter, + schemasPublicRouter, +} from "@/features/capabilities/api/public" import { contactScanPublicRouter } from "@/features/contact-scan/api/public" import { contactsPublicRouter } from "@/features/contacts/api/public" import { conversationsPublicRouter } from "@/features/conversations/api/public" @@ -48,6 +52,7 @@ import { savedRepliesPublicRouter } from "@/features/saved-replies/api/public" import { sequencesPublicRouter } from "@/features/sequences/api/public" import { spreadsheetsPublicRouter } from "@/features/spreadsheets/api/public" import { tagsPublicRouter } from "@/features/tags/api/public" +import { tokenPublicRouter } from "@/features/token/api/public" import { triggersPublicRouter } from "@/features/triggers/api/public" import { userPersistentMenusPublicRouter } from "@/features/user-persistent-menus/api/public" import { webhooksPublicRouter } from "@/features/webhooks/api/public" @@ -66,6 +71,7 @@ export const publicRouter = { appointments: appointmentsPublicRouter, botFields: botFieldsPublicRouter, broadcasts: broadcastsPublicRouter, + capabilities: capabilitiesPublicRouter, channels: channelsPublicRouter, contactScans: contactScanPublicRouter, contacts: contactsPublicRouter, @@ -102,10 +108,12 @@ export const publicRouter = { spreadsheets: spreadsheetsPublicRouter, tags: tagsPublicRouter, templateMessages: templateMessagesPublicRouter, + token: tokenPublicRouter, triggers: triggersPublicRouter, userPersistentMenus: userPersistentMenusPublicRouter, webchats: webchatsPublicRouter, webhooks: webhooksPublicRouter, workspaceMembers: workspaceMembersPublicRouter, zaloChannels: zaloChannelsPublicRouter, + schemas: schemasPublicRouter, } diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example index ed0afd38b5..eb3c2fc28b 100644 --- a/apps/mcp-server/.env.example +++ b/apps/mcp-server/.env.example @@ -12,6 +12,10 @@ CHATBOTX_API_URL= # Only use in local development with self-signed certificates. CHATBOTX_ALLOW_SELF_SIGNED_CERT= +# How long the fetched OpenAPI spec (and its derived tool list) is trusted +# before the next tools/list call triggers a background re-fetch, in ms. +CHATBOTX_SPEC_TTL_MS=300000 + # ─── MCP Server (SSE / HTTP transport only) ────────────────────────────────── # Transport mode: "stdio" | "sse" | "both" @@ -32,6 +36,10 @@ CHATBOTX_MCP_SSE_PATH=/sse # URL path for the JSON-RPC messages endpoint CHATBOTX_MCP_MESSAGES_PATH=/messages +# CORS origin allowed to call the SSE/HTTP endpoints (e.g. https://chat.openai.com). +# "*" allows any origin — fine for local development, tighten for a public deployment. +CHATBOTX_MCP_CORS_ORIGIN=* + # ─── Server identity (sent to AI clients on connect) ───────────────────────── # Display name shown to AI clients (e.g. Claude, ChatGPT). diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 878d6ca12d..aae606316c 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -4,102 +4,107 @@ ## How it works -On startup the server fetches `{CHATBOTX_API_URL}/public-spec.json` and registers one MCP tool per API operation. `CHATBOTX_API_URL` is the origin **including** the `/api` path prefix (e.g. `https://app.chatbotx.io/api`). Adding a new API endpoint in ChatbotX automatically makes it available as a tool on the next server restart — no code changes required. Tool names are cached in-process for the server's lifetime, so a renamed operation requires a restart to pick up. +On startup the server fetches `{CHATBOTX_API_URL}/public-spec.json` and registers one MCP tool per API operation the spec marks `x-mcp.visibility: "default"`. `CHATBOTX_API_URL` is the origin **including** the `/api` path prefix (e.g. `https://app.chatbotx.io/api`). Adding a new default-visible API endpoint in ChatbotX automatically makes it available as a tool — the spec is re-fetched in the background whenever it's older than `CHATBOTX_SPEC_TTL_MS` (default 5 minutes), so a new/changed operation shows up on the next `tools/list` call without a server restart. + +### Default tools vs. the full API + +ChatbotX's public API has ~300 operations. Listing all of them as MCP tools overwhelms an agent's context and its ability to pick the right one, so `tools/list` returns only a curated default set (see below) plus two meta-tools: + +| Tool | Description | +|---|---| +| `search_tools` | Search the full API for a tool not in the default set. Returns each match's name, description, and input schema. | +| `call_tool` | Execute any tool by name, including ones `search_tools` found but `tools/list` doesn't show. | + +Use `search_tools` when the task needs something outside the default set (e.g. deleting a resource, managing AI agents, coupons, products) — then invoke it with `call_tool`. + +### Scope-based filtering + +`tools/list` is further narrowed to what the calling token can actually use, resolved once per token via `GET /v1/token` (`introspectToken`, cached per token value for `CHATBOTX_SPEC_TTL_MS`): + +- A token missing a scope never sees that scope's tools (they still exist for `search_tools`/`call_tool`, which always hit the real API and get a real 403 if unauthorized). +- A `read_only` token only sees `GET` tools, plus a small allowlist of POST endpoints that are reads in disguise (currently `contacts_search`, a filter-body search). +- `capabilities_get` and `token_get` are always visible regardless of scope — an agent needs them to discover what it *can* do and what its token allows before anything else works. +- If token introspection itself fails (network blip, unreachable API), filtering fails open — `tools/list` falls back to the full default set. The actual API call still enforces the token's real permissions either way. + +### Discovery tools an agent should call first + +| Tool | Description | +|---|---| +| `capabilities_get` | Discover the workspace's inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — the ids a flow spec or a message needs to reference. | +| `token_get` | Get the calling token's workspace id, permission (`read_only`/`full`), and scopes — check before attempting a write. | +| `schemas_flow_spec` | Get the JSON Schema for the `spec` object `flows_create`/`flows_publish`/`flows_validate` accept — the authoritative reference for every flow step type. | ## Available tools -Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). Operations under `/v1/channels/api/*` (channel-token-authed) and deprecated operations (e.g. `inboxes.listChannels`) are excluded — they require a different token type or are kept only for backward compatibility. The current set of 66 tools: +Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). The current default set has 44 tools: -### AI Agents +### Capabilities | Tool | Description | |---|---| -| `ai_agents_list` | List AI agents | +| `capabilities_get` | Discover the workspace's inboxes, templates, fields, tags, sequences, and flows | +| `schemas_flow_spec` | Get the JSON Schema for the flow-spec DSL | +| `token_get` | Get the calling token's workspace id, permission, and scopes | -### Bot Fields +### Analytics | Tool | Description | |---|---| -| `bot_fields_list` | Get all bot fields | -| `bot_fields_create` | Create a new bot field | -| `bot_fields_set_many` | Set multiple bot field values | -| `bot_fields_bulk_update` | Bulk update bot field values by id or name | -| `bot_fields_get` | Get bot field by id or name | -| `bot_fields_set` | Set bot field value by id or name | -| `bot_fields_delete` | Unset the value of the bot field by id or name | +| `analytics_contacts_count` | Get contacts count | +| `analytics_new_contacts_count` | Get new contacts count | ### Broadcasts | Tool | Description | |---|---| +| `broadcasts_create` | Create a broadcast | | `broadcasts_list` | Get all broadcasts | -| `broadcasts_get` | Get broadcast by id or name | -| `broadcasts_get_audience` | Get broadcast audience | +| `broadcasts_schedule` | Schedule a draft broadcast | +| `broadcasts_stop` | Stop a broadcast that is currently sending | ### Contacts | Tool | Description | |---|---| -| `contacts_list` | List contacts | +| `contacts_add_tags` | Add tags to the contact | +| `contacts_count` | Count contacts matching a filter | | `contacts_create` | Create a contact | | `contacts_get` | Get contact by identifier (id:123, email:user@example.com, phone:+84...) | -| `contacts_upsert` | Upsert a contact by identifier | -| `contacts_update` | Update contact fields | -| `contacts_delete` | Delete a contact | -| `contacts_find_by_custom_field` | List contacts by custom field | -| `contacts_import` | Import contacts from a file | -| `contacts_list_tags` | Get all tags added to this contact | -| `contacts_add_tags` | Add tags to the contact | -| `contacts_remove_tags` | Remove tags from the contact | -| `contacts_list_custom_fields` | Get all custom fields from a contact | -| `contacts_set_custom_fields` | Set multiple custom field values for a contact | -| `contacts_clear_custom_fields` | Clear all custom fields from a contact | -| `contacts_get_custom_field` | Get contact custom field value | -| `contacts_set_custom_field` | Set contact custom field value | -| `contacts_clear_custom_field` | Delete contact custom field by id or name | -| `contacts_block` | Block a contact | -| `contacts_unblock` | Unblock a contact | -| `contacts_list_messages` | List messages for contact | -| `contacts_get_message` | Get a message by ID for a contact | -| `contacts_send_message` | Send message to contact | +| `contacts_list` | List contacts | +| `contacts_list_filter_fields` | List every field usable in a contact filter | +| `contacts_search` | Search contacts with a filter body | | `contacts_send_flow` | Send flow to contact | -| `contacts_trigger_auto_reply` | Trigger auto reply for contact | +| `contacts_send_message` | Send message to contact | +| `contacts_set_custom_fields` | Set multiple custom field values for a contact | +| `contacts_subscribe_sequences` | Enroll the contact in one or more sequences | +| `contacts_update` | Update contact fields | +| `contacts_upsert` | Upsert a contact by identifier | ### Conversations | Tool | Description | |---|---| +| `conversations_archive` | Archive a conversation | +| `conversations_assign` | Assign or unassign a conversation to a user or inbox team | +| `conversations_get` | Get a conversation by id | | `conversations_list` | List conversations | ### Custom Fields | Tool | Description | |---|---| -| `custom_fields_list` | Get all custom fields | | `custom_fields_create` | Create a custom field | -| `custom_fields_get` | Get custom field by id or name | -| `custom_fields_update` | Update custom field | -| `custom_fields_delete` | Delete custom field | - -### Error Logs - -| Tool | Description | -|---|---| -| `error_logs_list` | List error logs | - -### External Webhooks - -| Tool | Description | -|---|---| -| `external_webhooks_list` | List external webhooks | -| `external_webhooks_create` | Register an external webhook | -| `external_webhooks_delete` | Unregister an external webhook | +| `custom_fields_list` | Get all custom fields | ### Flows | Tool | Description | |---|---| -| `flows_list` | Get all flows | +| `flows_create` | Create a flow | +| `flows_get` | Get a flow by id | +| `flows_list` | List flows | +| `flows_publish` | Publish a flow | +| `flows_validate` | Compile and validate a flow spec without publishing it | ### Inboxes @@ -107,79 +112,42 @@ Tool names are derived from the OpenAPI `operationId` converted to `snake_case` |---|---| | `inboxes_list` | List inboxes | -### Teams - -| Tool | Description | -|---|---| -| `inbox_teams_list` | List teams | - -### Integrations - -| Tool | Description | -|---|---| -| `integrations_list` | List integrations | - ### Keywords | Tool | Description | |---|---| +| `keywords_create` | Create a keyword automation | | `keywords_list` | List keywords (automated responses) | -### Ref Links - -| Tool | Description | -|---|---| -| `reflinks_get` | Get a specific ref link | - -### Saved Replies +### Messages | Tool | Description | |---|---| -| `saved_replies_list` | List saved replies | +| `messages_create` | Send a message on a conversation | +| `messages_list` | List messages on a conversation | ### Sequences | Tool | Description | |---|---| +| `sequences_create` | Create a sequence | | `sequences_list` | List sequences | -| `sequences_get` | Get sequence details | ### Tags | Tool | Description | |---|---| -| `tags_list` | Get all tags | | `tags_create` | Create a new tag | -| `tags_get` | Get tag by id or name | -| `tags_update` | Update tag | -| `tags_delete` | Delete tag | - -### Template Messages - -| Tool | Description | -|---|---| -| `template_messages_list` | List template messages | +| `tags_list` | Get all tags | ### Triggers | Tool | Description | |---|---| +| `triggers_create` | Create a trigger | | `triggers_list` | List triggers | -### Webhooks - -| Tool | Description | -|---|---| -| `webhooks_list` | List webhooks | -| `webhooks_create` | Register a webhook | -| `webhooks_delete` | Unregister a webhook | - -### Members - -| Tool | Description | -|---|---| -| `workspace_members_list` | List workspace members | -| `workspace_members_get` | Get workspace member by id | +Everything else — deletes, less-common resources (AI agents, coupons, products, webhooks, saved replies, error logs, integrations, workspace members, etc.), and channel-token-only or deprecated operations — is reachable via `search_tools` → `call_tool`, not `tools/list`. ## Prerequisites @@ -285,11 +253,13 @@ cp .env.example .env | `CHATBOTX_API_KEY` | Workspace token (stdio) | — | Yes (stdio) | | `CHATBOTX_API_URL` | ChatbotX API origin, including `/api` (e.g. `https://app.chatbotx.io/api`) | `https://api.chatbotx.io` | Yes | | `CHATBOTX_ALLOW_SELF_SIGNED_CERT` | Disable TLS verification (`true`/`false`) | — | No | +| `CHATBOTX_SPEC_TTL_MS` | How long the fetched OpenAPI spec/tool list and a token's introspected scopes are trusted before a background re-fetch | `300000` | No | | `CHATBOTX_MCP_TRANSPORT` | `stdio` \| `sse` \| `both` | `both` | No | | `CHATBOTX_MCP_HOST` | SSE server host | `0.0.0.0` | No | | `CHATBOTX_MCP_PORT` | SSE server port | `3333` | No | | `CHATBOTX_MCP_SSE_PATH` | SSE endpoint path | `/sse` | No | | `CHATBOTX_MCP_MESSAGES_PATH` | JSON-RPC messages path | `/messages` | No | +| `CHATBOTX_MCP_CORS_ORIGIN` | CORS origin allowed to call the SSE/HTTP endpoints | `*` | No | | `CHATBOTX_MCP_SERVER_NAME` | Display name sent to AI clients | package name | No | | `CHATBOTX_MCP_SERVER_INSTRUCTIONS` | Instructions sent to AI clients on connect (helps ChatGPT know when to call tools) | built-in default | No | @@ -316,12 +286,16 @@ dotenv -e .env -- tsx src/test-tools.ts ``` src/ -├── index.ts # Entry point — loads spec, starts transport(s) -├── env.ts # Environment variable schema -├── openapi-loader.ts # Fetches OpenAPI spec → DynamicTool list -├── test-tools.ts # Dev utility — prints loaded tools +├── index.ts # Entry point — loads spec, starts transport(s) +├── env.ts # Environment variable schema +├── openapi-loader.ts # Fetches OpenAPI spec → DynamicTool list, x-mcp +│ # visibility/scope parsing, scope-filtered getVisibleTools() +├── token-introspection.ts # GET /v1/token → cached {permission, scopes} per token +├── test-tools.ts # Dev utility — prints loaded tools └── server/ - ├── create-mcp-server.ts # MCP server factory + ├── create-mcp-server.ts # MCP server factory, tools/list + tools/call handlers + ├── meta-tools.ts # search_tools / call_tool definitions + ranking + ├── execute-tool.ts # Shared HTTP dispatch for a DynamicTool call ├── sse-server.ts # SSE / Streamable HTTP transport └── stdio-server.ts # stdio transport ``` @@ -330,7 +304,9 @@ src/ **Tools not showing up** - Check that `CHATBOTX_API_URL` is reachable and `{CHATBOTX_API_URL}/public-spec.json` returns a valid OpenAPI spec. -- Tool names are cached in-process for the server's lifetime — restart the server after a tool rename or a public API change. +- Only operations the spec marks `x-mcp.visibility: "default"` appear in `tools/list` — everything else is reachable via `search_tools`/`call_tool`. See "Default tools vs. the full API" above. +- The spec/tool list refreshes automatically every `CHATBOTX_SPEC_TTL_MS` (default 5 minutes); a brand-new operation may take that long to appear without a restart. +- A token missing a required scope, or a `read_only` token calling a write endpoint's tool, will not see that tool — see "Scope-based filtering" above. - Check stderr output on startup — the server logs `Loaded N tools from OpenAPI spec`. **Port already in use** diff --git a/apps/mcp-server/SKILL.md b/apps/mcp-server/SKILL.md index 48428b73ed..17efefe5ff 100644 --- a/apps/mcp-server/SKILL.md +++ b/apps/mcp-server/SKILL.md @@ -1,7 +1,7 @@ --- name: chatbotx description: ChatbotX is an open-source chat marketing platform for managing contacts, conversations, flows, broadcasts, and sequences across WhatsApp, Messenger, Instagram, TikTok, Telegram, Zalo OA, Email, and Webchat. An alternative to ManyChat, Chatfuel, Wati, Respond, etc... -version: 0.1.6 +version: 0.2.0 emoji: 🤖 homepage: https://github.com/ChatbotXIO/ChatbotX metadata: @@ -224,36 +224,37 @@ chatbotx error-logs list # [--page --perPage --sort ## MCP Tools (for AI agents) -Tool names are the OpenAPI `operationId` converted to `snake_case` (66 tools total). Channel-token operations (`/v1/channels/api/*`) and deprecated operations (e.g. `inboxes.listChannels`) are excluded from this surface. +Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 44 tools** — not the full ~300-operation API — plus two meta-tools that reach everything else: + +| Tool | Description | +|---|---| +| `search_tools` | Search the full API for a tool outside the default set (e.g. delete operations, AI agents, coupons, products, webhooks). Returns name/description/inputSchema. | +| `call_tool` | Execute any tool by name, including ones only `search_tools` found. | + +Call `capabilities_get` and `token_get` first — both are always visible regardless of the calling token's scopes: + +| Tool | Description | +|---|---| +| `capabilities_get` | Discover the workspace's inboxes, templates, fields, tags, sequences, and flows — the ids other tools need. | +| `token_get` | Get the calling token's workspace id, permission (`read_only`/`full`), and scopes. | +| `schemas_flow_spec` | JSON Schema for the flow-spec DSL `flows_create`/`flows_publish`/`flows_validate` accept. | | Category | Tool | |---|---| -| AI Agents | `ai_agents_list` | -| Bot Fields | `bot_fields_list`, `bot_fields_create`, `bot_fields_set_many`, `bot_fields_bulk_update`, `bot_fields_get`, `bot_fields_set`, `bot_fields_delete` | -| Broadcasts | `broadcasts_list`, `broadcasts_get`, `broadcasts_get_audience` | -| Contacts | `contacts_list`, `contacts_create`, `contacts_get`, `contacts_upsert`, `contacts_update`, `contacts_delete`, `contacts_find_by_custom_field`, `contacts_import` | -| Contact Tags | `contacts_list_tags`, `contacts_add_tags`, `contacts_remove_tags` | -| Contact Custom Fields | `contacts_list_custom_fields`, `contacts_set_custom_fields`, `contacts_clear_custom_fields`, `contacts_get_custom_field`, `contacts_set_custom_field`, `contacts_clear_custom_field` | -| Contact Actions | `contacts_block`, `contacts_unblock`, `contacts_list_messages`, `contacts_get_message`, `contacts_send_message`, `contacts_send_flow`, `contacts_trigger_auto_reply` | -| Conversations | `conversations_list` | -| Custom Fields | `custom_fields_list`, `custom_fields_create`, `custom_fields_get`, `custom_fields_update`, `custom_fields_delete` | -| Error Logs | `error_logs_list` | -| External Webhooks | `external_webhooks_list`, `external_webhooks_create`, `external_webhooks_delete` | -| Flows | `flows_list` | +| Analytics | `analytics_contacts_count`, `analytics_new_contacts_count` | +| Broadcasts | `broadcasts_create`, `broadcasts_list`, `broadcasts_schedule`, `broadcasts_stop` | +| Contacts | `contacts_add_tags`, `contacts_count`, `contacts_create`, `contacts_get`, `contacts_list`, `contacts_list_filter_fields`, `contacts_search`, `contacts_send_flow`, `contacts_send_message`, `contacts_set_custom_fields`, `contacts_subscribe_sequences`, `contacts_update`, `contacts_upsert` | +| Conversations | `conversations_archive`, `conversations_assign`, `conversations_get`, `conversations_list` | +| Custom Fields | `custom_fields_create`, `custom_fields_list` | +| Flows | `flows_create`, `flows_get`, `flows_list`, `flows_publish`, `flows_validate` | | Inboxes | `inboxes_list` | -| Teams | `inbox_teams_list` | -| Integrations | `integrations_list` | -| Keywords | `keywords_list` | -| Ref Links | `reflinks_get` | -| Saved Replies | `saved_replies_list` | -| Sequences | `sequences_list`, `sequences_get` | -| Tags | `tags_list`, `tags_create`, `tags_get`, `tags_update`, `tags_delete` | -| Template Messages | `template_messages_list` | -| Triggers | `triggers_list` | -| Webhooks | `webhooks_list`, `webhooks_create`, `webhooks_delete` | -| Members | `workspace_members_list`, `workspace_members_get` | - -Tools are auto-generated from the OpenAPI spec — new API endpoints appear automatically on server restart. Tool names are cached in-process for the server's lifetime, so a rename requires a restart. +| Keywords | `keywords_create`, `keywords_list` | +| Messages | `messages_create`, `messages_list` | +| Sequences | `sequences_create`, `sequences_list` | +| Tags | `tags_create`, `tags_list` | +| Triggers | `triggers_create`, `triggers_list` | + +A token missing a scope, or a `read_only` token calling a write tool, does not see that tool in `tools/list` (the underlying API call still 403s if forced via `call_tool`). Tools are auto-generated from the OpenAPI spec — new default-visible endpoints appear automatically once the spec's TTL (`CHATBOTX_SPEC_TTL_MS`, default 5 minutes) elapses, no restart required. --- diff --git a/apps/mcp-server/__tests__/meta-tools.test.ts b/apps/mcp-server/__tests__/meta-tools.test.ts new file mode 100644 index 0000000000..af05dfdbcc --- /dev/null +++ b/apps/mcp-server/__tests__/meta-tools.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +// Same convention as openapi-loader.test.ts: `getCachedTools()` is +// module-level state populated by `loadOpenApiSpec()`, so each test needs a +// fresh module instance (`vi.resetModules()`) and its own fetch mock rather +// than sharing the previous test's cached tools. +const specWithTools = ( + tools: Array<{ + name: string + summary: string + description?: string + method?: string + }>, +) => ({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: Object.fromEntries( + tools.map((tool) => [ + `/v1/${tool.name}`, + { + [(tool.method ?? "get").toLowerCase()]: { + operationId: tool.name, + summary: tool.summary, + description: tool.description, + }, + }, + ]), + ), + }), +}) + +describe("META_TOOLS", () => { + test("are exactly search_tools and call_tool", async () => { + const { META_TOOLS, META_TOOL_NAMES } = await import( + "../src/server/meta-tools" + ) + expect(META_TOOLS.map((tool) => tool.name)).toEqual([ + "search_tools", + "call_tool", + ]) + expect(Object.keys(META_TOOL_NAMES).sort()).toEqual([ + "call_tool", + "search_tools", + ]) + }) +}) + +describe("searchTools", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("ranks a name match above an unrelated tool", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + specWithTools([ + { name: "tags.list", summary: "Get all tags" }, + { + name: "minigames.list", + summary: "List minigames", + description: "Unrelated to tags entirely.", + }, + ]), + ) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { searchTools } = await import("../src/server/meta-tools") + + const results = searchTools("tags") + expect(results[0]?.name).toBe("tags_list") + }) + + test("drops zero-scoring tools instead of padding the tail", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + specWithTools([ + { name: "tags.list", summary: "Get all tags" }, + { name: "minigames.list", summary: "List minigames" }, + ]), + ) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { searchTools } = await import("../src/server/meta-tools") + + expect(searchTools("minigame").map((t) => t.name)).toEqual([ + "minigames_list", + ]) + }) + + test("caps results at 25 even when a higher limit is requested", async () => { + const manyTools = Array.from({ length: 30 }, (_, i) => ({ + name: `keyword${i}.list`, + summary: "keyword operation", + })) + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithTools(manyTools)) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { searchTools } = await import("../src/server/meta-tools") + + expect(searchTools("keyword", 100)).toHaveLength(25) + }) + + test("a GET tool ranks above a same-scoring non-GET tool", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + specWithTools([ + { name: "broadcasts.create", summary: "broadcast op", method: "post" }, + { name: "broadcasts.list", summary: "broadcast op", method: "get" }, + ]), + ) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { searchTools } = await import("../src/server/meta-tools") + + const results = searchTools("broadcast op") + expect(results[0]?.name).toBe("broadcasts_list") + }) +}) + +describe("handleSearchTools", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("rejects a missing query", async () => { + const { handleSearchTools } = await import("../src/server/meta-tools") + const result = handleSearchTools({}) + expect(result.isError).toBe(true) + }) + + test("returns matches as JSON with name/description/inputSchema", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + specWithTools([{ name: "tags.list", summary: "Get all tags" }]), + ) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { handleSearchTools } = await import("../src/server/meta-tools") + + const result = handleSearchTools({ query: "tags" }) + expect(result.isError).toBeUndefined() + const parsed = JSON.parse(result.content[0]?.text ?? "[]") + expect(parsed).toEqual([ + { + name: "tags_list", + description: "Get all tags", + inputSchema: expect.any(Object), + }, + ]) + }) +}) + +describe("handleCallTool", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("rejects a missing name", async () => { + const { handleCallTool } = await import("../src/server/meta-tools") + const result = await handleCallTool({}, "api-key") + expect(result.isError).toBe(true) + }) + + test("reports an unknown tool name", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithTools([])) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { handleCallTool } = await import("../src/server/meta-tools") + + const result = await handleCallTool({ name: "does_not_exist" }, "api-key") + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toContain("Unknown tool") + }) + + test("executes a tool found outside tools/list (hidden) by name", async () => { + const specFetch = vi + .fn() + .mockResolvedValueOnce( + specWithTools([{ name: "minigames.list", summary: "List minigames" }]), + ) + const executeFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: { + get: (name: string) => + name === "content-type" ? "application/json" : null, + }, + json: async () => ({ data: [] }), + }) + globalThis.fetch = vi + .fn() + .mockImplementationOnce(specFetch) + .mockImplementationOnce(executeFetch) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { handleCallTool } = await import("../src/server/meta-tools") + + const result = await handleCallTool( + { name: "minigames_list", arguments: {} }, + "api-key", + ) + expect(result.isError).toBeUndefined() + }) +}) diff --git a/apps/mcp-server/__tests__/openapi-loader.test.ts b/apps/mcp-server/__tests__/openapi-loader.test.ts index f4a23287d8..53626c1c56 100644 --- a/apps/mcp-server/__tests__/openapi-loader.test.ts +++ b/apps/mcp-server/__tests__/openapi-loader.test.ts @@ -272,3 +272,270 @@ describe("refreshOpenApiSpecIfStale", () => { expect(result1).toBe(result2) }) }) + +describe("x-mcp visibility, scope, and annotations", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + const specWithOperation = (operation: Record) => ({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { + "/v1/tags": { get: { operationId: "tags.list", ...operation } }, + }, + }), + }) + + test("an operation with no x-mcp is hidden", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithOperation({})) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + expect(tool?.visibility).toBe("hidden") + }) + + test("x-mcp.visibility: 'default' makes the tool visible; scope is carried through", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + specWithOperation({ + "x-mcp": { visibility: "default", scope: "contacts" }, + }), + ) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + expect(tool?.visibility).toBe("default") + expect(tool?.scope).toBe("contacts") + }) + + test("annotations default from the HTTP method when x-mcp doesn't override them", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithOperation({})) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + expect(tool?.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }) + }) + + test("x-mcp annotation hints override the method-inferred defaults", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + specWithOperation({ + "x-mcp": { visibility: "default", destructiveHint: true }, + }), + ) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + expect(tool?.annotations.destructiveHint).toBe(true) + // readOnlyHint/idempotentHint still fall back to the GET-method default. + expect(tool?.annotations.readOnlyHint).toBe(true) + }) + + test("a DELETE operation defaults to destructive and idempotent, not read-only", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { "/v1/tags/{id}": { delete: { operationId: "tags.delete" } } }, + }), + }) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + expect(tool?.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + }) + }) +}) + +describe("getVisibleTools", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("returns only visibility: 'default' tools, independent of getCachedTools", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { + "/v1/tags": { + get: { + operationId: "tags.list", + summary: "List tags", + "x-mcp": { visibility: "default" }, + }, + }, + "/v1/minigames": { + get: { operationId: "minigames.list", summary: "List minigames" }, + }, + }, + }), + }) as unknown as typeof fetch + + const { loadOpenApiSpec, getCachedTools, getVisibleTools } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect( + getCachedTools() + .map((t) => t.name) + .sort(), + ).toEqual(["minigames_list", "tags_list"]) + expect(getVisibleTools().map((t) => t.name)).toEqual(["tags_list"]) + }) + + const specWithScopedTools = () => ({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { + "/v1/tags": { + get: { + operationId: "tags.list", + summary: "List tags", + "x-mcp": { visibility: "default", scope: "contacts" }, + }, + }, + "/v1/flows": { + get: { + operationId: "flows.list", + summary: "List flows", + "x-mcp": { visibility: "default", scope: "automation" }, + }, + }, + "/v1/contacts/search": { + post: { + operationId: "contacts.search", + summary: "Search contacts", + "x-mcp": { visibility: "default", scope: "contacts" }, + }, + }, + "/v1/tags/{id}": { + delete: { + operationId: "tags.delete", + summary: "Delete a tag", + "x-mcp": { visibility: "default", scope: "contacts" }, + }, + }, + "/v1/capabilities": { + get: { + operationId: "capabilities.get", + summary: "Discover capabilities", + "x-mcp": { + visibility: "default", + scope: "contacts", + alwaysVisible: true, + }, + }, + }, + }, + }), + }) + + test("introspection: null (fetch failed) fails open — no scope filtering", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithScopedTools()) as unknown as typeof fetch + const { loadOpenApiSpec, getVisibleTools } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect( + getVisibleTools(null) + .map((t) => t.name) + .sort(), + ).toEqual([ + "capabilities_get", + "contacts_search", + "flows_list", + "tags_delete", + "tags_list", + ]) + }) + + test("scopes: null means unrestricted — every default tool stays visible", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithScopedTools()) as unknown as typeof fetch + const { loadOpenApiSpec, getVisibleTools } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect( + getVisibleTools({ permission: "full", scopes: null }) + .map((t) => t.name) + .sort(), + ).toEqual([ + "capabilities_get", + "contacts_search", + "flows_list", + "tags_delete", + "tags_list", + ]) + }) + + test("a scoped token only sees tools whose scope it holds, plus alwaysVisible tools", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithScopedTools()) as unknown as typeof fetch + const { loadOpenApiSpec, getVisibleTools } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect( + getVisibleTools({ permission: "full", scopes: ["automation"] }) + .map((t) => t.name) + .sort(), + ).toEqual(["capabilities_get", "flows_list"]) + }) + + test("a read_only token only sees GET tools plus the read-disguised-as-POST allowlist", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(specWithScopedTools()) as unknown as typeof fetch + const { loadOpenApiSpec, getVisibleTools } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect( + getVisibleTools({ + permission: "read_only", + scopes: ["contacts", "automation"], + }) + .map((t) => t.name) + .sort(), + ).toEqual([ + "capabilities_get", + "contacts_search", + "flows_list", + "tags_list", + ]) + }) +}) diff --git a/apps/mcp-server/__tests__/sse-server.test.ts b/apps/mcp-server/__tests__/sse-server.test.ts new file mode 100644 index 0000000000..bca1882a95 --- /dev/null +++ b/apps/mcp-server/__tests__/sse-server.test.ts @@ -0,0 +1,134 @@ +import type { IncomingMessage } from "node:http" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +// Dynamic per-test `import()` (not a static top-level import) matches this +// repo's existing `openapi-loader.test.ts` convention: `sse-server.ts` +// reads `env` from `@t3-oss/env-core` at module-eval time, so a test that +// mutates `process.env.CHATBOTX_API_KEY` must pair `vi.resetModules()` +// with a fresh import to see it — a static import would read `env` once, +// frozen at the first test's `process.env`, for the whole file. + +const fakeRequest = (props: { + url?: string + headers?: Record +}): IncomingMessage => + ({ + url: props.url ?? "/sse", + headers: props.headers ?? {}, + }) as unknown as IncomingMessage + +describe("resolveHeaderValue", () => { + test("returns a trimmed string header as-is", async () => { + const { resolveHeaderValue } = await import("../src/server/sse-server") + expect(resolveHeaderValue(" abc123 ")).toBe("abc123") + }) + + test("returns the first non-empty entry of an array header", async () => { + const { resolveHeaderValue } = await import("../src/server/sse-server") + expect(resolveHeaderValue(["", " ", "abc123"])).toBe("abc123") + }) + + test("returns empty string for undefined", async () => { + const { resolveHeaderValue } = await import("../src/server/sse-server") + expect(resolveHeaderValue(undefined)).toBe("") + }) +}) + +describe("getApiTokenFromRequest — priority order", () => { + test("?workspace_token= wins over everything else", async () => { + const { getApiTokenFromRequest } = await import("../src/server/sse-server") + const req = fakeRequest({ + url: "/sse?workspace_token=from-query&token=ignored", + headers: { + "x-workspace-token": "ignored-header", + "x-chatbo-token": "ignored-header-2", + }, + }) + expect(getApiTokenFromRequest(req)).toBe("from-query") + }) + + test("?token= is used when workspace_token is absent", async () => { + const { getApiTokenFromRequest } = await import("../src/server/sse-server") + const req = fakeRequest({ url: "/sse?token=legacy-query" }) + expect(getApiTokenFromRequest(req)).toBe("legacy-query") + }) + + test("x-workspace-token header wins over x-chatbo-token", async () => { + const { getApiTokenFromRequest } = await import("../src/server/sse-server") + const req = fakeRequest({ + headers: { + "x-workspace-token": "primary-header", + "x-chatbo-token": "fallback-header", + }, + }) + expect(getApiTokenFromRequest(req)).toBe("primary-header") + }) + + test("x-chatbo-token is used when x-workspace-token is absent", async () => { + const { getApiTokenFromRequest } = await import("../src/server/sse-server") + const req = fakeRequest({ + headers: { "x-chatbo-token": "fallback-header" }, + }) + expect(getApiTokenFromRequest(req)).toBe("fallback-header") + }) + + test("returns undefined when the request carries no token", async () => { + const { getApiTokenFromRequest } = await import("../src/server/sse-server") + expect(getApiTokenFromRequest(fakeRequest({}))).toBeUndefined() + }) +}) + +describe("makeApiKeyState / updateApiKeyStateFromRequest", () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.resetModules() + process.env.CHATBOTX_API_KEY = "env-default-token" + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + test("falls back to CHATBOTX_API_KEY when the connect request carries no token", async () => { + const { makeApiKeyState } = await import("../src/server/sse-server") + const state = makeApiKeyState(fakeRequest({})) + expect(state.current).toBe("env-default-token") + }) + + test("seeds state from the connect request's token when present", async () => { + const { makeApiKeyState } = await import("../src/server/sse-server") + const state = makeApiKeyState( + fakeRequest({ url: "/sse?workspace_token=connect-token" }), + ) + expect(state.current).toBe("connect-token") + }) + + test("a later request carrying a token overwrites the session's current token", async () => { + const { makeApiKeyState, updateApiKeyStateFromRequest } = await import( + "../src/server/sse-server" + ) + const state = makeApiKeyState( + fakeRequest({ url: "/sse?workspace_token=token-a" }), + ) + expect(state.current).toBe("token-a") + + updateApiKeyStateFromRequest( + state, + fakeRequest({ headers: { "x-workspace-token": "token-b" } }), + ) + expect(state.current).toBe("token-b") + }) + + test("a later request carrying no token leaves the current token untouched", async () => { + const { makeApiKeyState, updateApiKeyStateFromRequest } = await import( + "../src/server/sse-server" + ) + const state = makeApiKeyState( + fakeRequest({ url: "/sse?workspace_token=token-a" }), + ) + + updateApiKeyStateFromRequest(state, fakeRequest({})) + expect(state.current).toBe("token-a") + }) +}) diff --git a/apps/mcp-server/__tests__/token-introspection.test.ts b/apps/mcp-server/__tests__/token-introspection.test.ts new file mode 100644 index 0000000000..1ee194cddf --- /dev/null +++ b/apps/mcp-server/__tests__/token-introspection.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +describe("introspectToken", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + vi.useRealTimers() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.useRealTimers() + }) + + test("fetches GET /v1/token with the token as a bearer credential", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + workspaceId: "workspace-1", + permission: "full", + scopes: ["contacts"], + }), + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + const result = await introspectToken("token-abc") + + expect(result).toEqual({ + workspaceId: "workspace-1", + permission: "full", + scopes: ["contacts"], + }) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toContain("/v1/token") + expect(init?.headers).toMatchObject({ Authorization: "Bearer token-abc" }) + }) + + test("returns null on a non-2xx response instead of throwing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }) as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await expect(introspectToken("token-bad")).resolves.toBeNull() + }) + + test("returns null when the fetch itself rejects", async () => { + globalThis.fetch = vi + .fn() + .mockRejectedValue(new Error("network down")) as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await expect(introspectToken("token-x")).resolves.toBeNull() + }) + + test("caches a successful result by the raw token value within the TTL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + workspaceId: "workspace-1", + permission: "full", + scopes: null, + }), + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-cached") + await introspectToken("token-cached") + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test("different token values get independent cache entries", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + workspaceId: "workspace-1", + permission: "full", + scopes: null, + }), + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-a") + await introspectToken("token-b") + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + test("re-fetches once the cache entry's TTL has elapsed", async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + workspaceId: "workspace-1", + permission: "full", + scopes: null, + }), + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-ttl") + // Default CHATBOTX_SPEC_TTL_MS is 300_000ms. + vi.advanceTimersByTime(300_001) + await introspectToken("token-ttl") + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/mcp-server/docker/rootfs/usr/local/bin/docker-entrypoint.sh b/apps/mcp-server/docker/rootfs/usr/local/bin/docker-entrypoint.sh index a1648b3b6c..6f5e9f7502 100755 --- a/apps/mcp-server/docker/rootfs/usr/local/bin/docker-entrypoint.sh +++ b/apps/mcp-server/docker/rootfs/usr/local/bin/docker-entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/sh # --enable-source-maps resolves production stack traces back to original TypeScript source. -NODE_OPTIONS="--no-node-snapshot --enable-source-maps" HOSTNAME=${HOSTNAME:-0.0.0.0} PORT=${PORT:-3000} node apps/mcp-server/dist/index.mjs; +NODE_OPTIONS="--no-node-snapshot --enable-source-maps" CHATBOTX_MCP_HOST=${CHATBOTX_MCP_HOST:-0.0.0.0} CHATBOTX_MCP_PORT=${CHATBOTX_MCP_PORT:-3333} node apps/mcp-server/dist/index.mjs; diff --git a/apps/mcp-server/src/openapi-loader.ts b/apps/mcp-server/src/openapi-loader.ts index e4db2d39b7..90f60e2e04 100644 --- a/apps/mcp-server/src/openapi-loader.ts +++ b/apps/mcp-server/src/openapi-loader.ts @@ -5,6 +5,15 @@ interface OpenAPISpec { servers?: Array<{ url: string }> } +interface McpOperationMeta { + alwaysVisible?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + readOnlyHint?: boolean + scope?: string + visibility?: "default" | "hidden" +} + interface OpenAPIOperation { deprecated?: boolean description?: string @@ -20,6 +29,7 @@ interface OpenAPIOperation { } security?: Record[] summary?: string + "x-mcp"?: McpOperationMeta } // Workspace-token security schemes only — a channel-token op (or any scheme @@ -74,7 +84,15 @@ interface OpenAPISchemaObject { type?: string } +export interface DynamicToolAnnotations { + destructiveHint: boolean + idempotentHint: boolean + readOnlyHint: boolean +} + export interface DynamicTool { + alwaysVisible: boolean + annotations: DynamicToolAnnotations baseUrl: string bodyParamNames: string[] description: string @@ -88,6 +106,8 @@ export interface DynamicTool { pathParamNames: string[] pathTemplate: string queryParamNames: string[] + scope?: string + visibility: "default" | "hidden" } let cachedTools: DynamicTool[] | null = null @@ -115,6 +135,28 @@ function extractPathParamNames(pathTemplate: string): string[] { return matches ? matches.map((m) => m.slice(1, -1)) : [] } +/** + * `x-mcp` hints override; otherwise inferred from the HTTP method — a GET is + * read-only and idempotent by convention, a DELETE is destructive (and still + * idempotent: deleting twice is a no-op), everything else (POST/PUT/PATCH) + * defaults to none of the three. + */ +function buildAnnotations( + method: string, + meta: McpOperationMeta | undefined, +): DynamicToolAnnotations { + const upperMethod = method.toUpperCase() + return { + readOnlyHint: meta?.readOnlyHint ?? upperMethod === "GET", + destructiveHint: meta?.destructiveHint ?? upperMethod === "DELETE", + idempotentHint: + meta?.idempotentHint ?? + (upperMethod === "GET" || + upperMethod === "PUT" || + upperMethod === "DELETE"), + } +} + /** * `summary` is the short, always-present label; `description` (when a route * sets one) carries the long-form usage guidance an agent needs to pick the @@ -206,6 +248,7 @@ function parseToolsFromSpec(spec: OpenAPISpec): DynamicTool[] { const { schema, bodyParamNames, queryParamNames } = buildInputSchema(operation) + const meta = operation["x-mcp"] tools.push({ name: toSnakeCase(operation.operationId), description: buildToolDescription(operation), @@ -216,6 +259,10 @@ function parseToolsFromSpec(spec: OpenAPISpec): DynamicTool[] { pathParamNames, bodyParamNames, queryParamNames, + visibility: meta?.visibility === "default" ? "default" : "hidden", + alwaysVisible: meta?.alwaysVisible === true, + scope: meta?.scope, + annotations: buildAnnotations(httpMethod, meta), }) } } @@ -310,3 +357,59 @@ export async function refreshOpenApiSpecIfStale(): Promise { export function getCachedTools(): DynamicTool[] { return cachedTools ?? [] } + +export type TokenScopeIntrospection = { + permission: "read_only" | "full" + scopes: string[] | null +} + +// Plan carve-out: a read-only token still needs the handful of POST +// endpoints that are reads in disguise (a filter body instead of query +// params) — `contacts_search` mirrors `contacts_list` exactly. +const READ_ONLY_ALLOWED_POST_TOOLS: Record = { + contacts_search: true, +} + +function isVisibleForScope( + tool: DynamicTool, + introspection: TokenScopeIntrospection | null, +): boolean { + // Fail OPEN: introspection unavailable (network blip, unexpected + // response) must not hide every tool — enforcement of scope/permission + // still happens server-side on the actual call; this is a `tools/list` + // display concern only. + if (introspection === null) { + return true + } + + if ( + introspection.permission === "read_only" && + tool.method !== "GET" && + !READ_ONLY_ALLOWED_POST_TOOLS[tool.name] + ) { + return false + } + + // `alwaysVisible` (discovery endpoints like `capabilities.get`/`token.get`) + // and an unrestricted token (`scopes: null`) both skip the scope check. + if (tool.alwaysVisible || introspection.scopes === null) { + return true + } + + return tool.scope !== undefined && introspection.scopes.includes(tool.scope) +} + +/** + * The `tools/list` surface — `visibility: "default"` operations only, + * further narrowed to what `introspection` (the calling token's + * permission/scopes, from `GET /v1/token`) allows when provided. Everything + * excluded here is still reachable via `search_tools` / `call_tool` against + * `getCachedTools()`, which is never filtered. + */ +export function getVisibleTools( + introspection?: TokenScopeIntrospection | null, +): DynamicTool[] { + return getCachedTools() + .filter((tool) => tool.visibility === "default") + .filter((tool) => isVisibleForScope(tool, introspection ?? null)) +} diff --git a/apps/mcp-server/src/server/create-mcp-server.ts b/apps/mcp-server/src/server/create-mcp-server.ts index 687e878aa8..9c1000e5da 100644 --- a/apps/mcp-server/src/server/create-mcp-server.ts +++ b/apps/mcp-server/src/server/create-mcp-server.ts @@ -8,11 +8,16 @@ import { version as packageVersion, } from "../../package.json" import { env } from "../env" +import { getVisibleTools, refreshOpenApiSpecIfStale } from "../openapi-loader" +import { introspectToken } from "../token-introspection" +import { executeTool } from "./execute-tool" import { - type DynamicTool, - getCachedTools, - refreshOpenApiSpecIfStale, -} from "../openapi-loader" + findToolByName, + handleCallTool, + handleSearchTools, + META_TOOL_NAMES, + META_TOOLS, +} from "./meta-tools" export type CreateMcpServerOptions = { getApiKey?: () => string @@ -25,99 +30,8 @@ type InputSchema = { [key: string]: unknown } -const NO_BODY_METHODS = new Set(["GET", "HEAD", "DELETE"]) - -function buildQueryString(params: Record): string { - const qs = new URLSearchParams(params).toString() - return qs ? `?${qs}` : "" -} - -async function executeTool( - tool: DynamicTool, - args: Record, - apiKey: string, -): Promise<{ - content: Array<{ type: "text"; text: string }> - isError?: boolean -}> { - let path = tool.pathTemplate - - for (const paramName of tool.pathParamNames) { - const value = args[paramName] - if (value === undefined || value === null) { - return { - isError: true, - content: [ - { - type: "text", - text: `Missing required path parameter: ${paramName}`, - }, - ], - } - } - path = path.replace(`{${paramName}}`, encodeURIComponent(String(value))) - } - - const queryArgs: Record = {} - for (const key of tool.queryParamNames) { - const value = args[key] - if (value !== undefined && value !== null) { - queryArgs[key] = String(value) - } - } - - const body: Record = {} - for (const key of tool.bodyParamNames) { - if (args[key] !== undefined) { - body[key] = args[key] - } - } - - const url = `${tool.baseUrl}${path}${buildQueryString(queryArgs)}` - const sendBody = - !NO_BODY_METHODS.has(tool.method) && tool.bodyParamNames.length > 0 - - try { - const response = await fetch(url, { - method: tool.method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: sendBody ? JSON.stringify(body) : undefined, - }) - - let result: unknown - const contentType = response.headers.get("content-type") ?? "" - if (contentType.includes("application/json")) { - result = await response.json() - } else { - result = await response.text() - } - - if (!response.ok) { - return { - isError: true, - content: [ - { - type: "text", - text: `Error ${response.status}:\n${JSON.stringify(result, null, 2)}`, - }, - ], - } - } - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error" - return { - isError: true, - content: [{ type: "text", text: `Request failed: ${message}` }], - } - } -} +const NO_API_KEY_MESSAGE = + "No workspace token configured. Set CHATBOTX_API_KEY in the server environment or pass the token via the ?workspace_token= URL query parameter." export const createMcpServer = ( options?: CreateMcpServerOptions, @@ -138,46 +52,51 @@ export const createMcpServer = ( mcpServer.server.registerCapabilities({ tools: {} }) mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools = await refreshOpenApiSpecIfStale() + await refreshOpenApiSpecIfStale() + const apiKey = getApiKey() + const introspection = apiKey ? await introspectToken(apiKey) : null + // `META_TOOLS` come first: they are the fixed discovery path into the + // ~300 operations `getVisibleTools()` excludes. return { - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema as InputSchema, - })), + tools: [ + ...META_TOOLS, + ...getVisibleTools(introspection).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as InputSchema, + annotations: tool.annotations, + })), + ], } }) mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params + const toolArgs = (args ?? {}) as Record const apiKey = getApiKey() if (!apiKey) { return { isError: true, - content: [ - { - type: "text", - text: "No workspace token configured. Set CHATBOTX_API_KEY in the server environment or pass the token via the ?workspace_token= URL query parameter.", - }, - ], + content: [{ type: "text" as const, text: NO_API_KEY_MESSAGE }], } } - const tool = getCachedTools().find((t) => t.name === name) + if (name in META_TOOL_NAMES) { + return name === "search_tools" + ? handleSearchTools(toolArgs) + : await handleCallTool(toolArgs, apiKey) + } + const tool = findToolByName(name) if (!tool) { return { isError: true, - content: [{ type: "text", text: `Unknown tool: ${name}` }], + content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], } } - return await executeTool( - tool, - (args ?? {}) as Record, - apiKey, - ) + return await executeTool(tool, toolArgs, apiKey) }) return mcpServer diff --git a/apps/mcp-server/src/server/execute-tool.ts b/apps/mcp-server/src/server/execute-tool.ts new file mode 100644 index 0000000000..66f3c88a52 --- /dev/null +++ b/apps/mcp-server/src/server/execute-tool.ts @@ -0,0 +1,107 @@ +import type { DynamicTool } from "../openapi-loader" + +const NO_BODY_METHODS: Record = { + GET: true, + HEAD: true, + DELETE: true, +} + +function buildQueryString(params: Record): string { + const qs = new URLSearchParams(params).toString() + return qs ? `?${qs}` : "" +} + +export type ToolCallResult = { + content: Array<{ type: "text"; text: string }> + isError?: boolean +} + +/** + * Fires the HTTP request a `DynamicTool` describes. Shared by the normal + * `tools/call` path (`create-mcp-server.ts`) and `call_tool` + * (`meta-tools.ts`) — the latter looks a tool up outside `tools/list`'s + * `visibility: "default"` filter, but execution is identical either way. + */ +export async function executeTool( + tool: DynamicTool, + args: Record, + apiKey: string, +): Promise { + let path = tool.pathTemplate + + for (const paramName of tool.pathParamNames) { + const value = args[paramName] + if (value === undefined || value === null) { + return { + isError: true, + content: [ + { + type: "text", + text: `Missing required path parameter: ${paramName}`, + }, + ], + } + } + path = path.replace(`{${paramName}}`, encodeURIComponent(String(value))) + } + + const queryArgs: Record = {} + for (const key of tool.queryParamNames) { + const value = args[key] + if (value !== undefined && value !== null) { + queryArgs[key] = String(value) + } + } + + const body: Record = {} + for (const key of tool.bodyParamNames) { + if (args[key] !== undefined) { + body[key] = args[key] + } + } + + const url = `${tool.baseUrl}${path}${buildQueryString(queryArgs)}` + const sendBody = + !NO_BODY_METHODS[tool.method] && tool.bodyParamNames.length > 0 + + try { + const response = await fetch(url, { + method: tool.method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: sendBody ? JSON.stringify(body) : undefined, + }) + + let result: unknown + const contentType = response.headers.get("content-type") ?? "" + if (contentType.includes("application/json")) { + result = await response.json() + } else { + result = await response.text() + } + + if (!response.ok) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error ${response.status}:\n${JSON.stringify(result, null, 2)}`, + }, + ], + } + } + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error" + return { + isError: true, + content: [{ type: "text", text: `Request failed: ${message}` }], + } + } +} diff --git a/apps/mcp-server/src/server/meta-tools.ts b/apps/mcp-server/src/server/meta-tools.ts new file mode 100644 index 0000000000..b87588759c --- /dev/null +++ b/apps/mcp-server/src/server/meta-tools.ts @@ -0,0 +1,204 @@ +import { type DynamicTool, getCachedTools } from "../openapi-loader" +import { executeTool, type ToolCallResult } from "./execute-tool" + +/** + * Static tool definitions for the two meta-tools that give an agent access + * to the ~300 operations excluded from `tools/list` by `visibility: "hidden"` + * (see `apps/builder/src/lib/orpc/mcp-annotations.ts`). These never come + * from the OpenAPI spec — they are the fixed entry point into it. + */ +export const META_TOOLS = [ + { + name: "search_tools", + description: + "Search the full ChatbotX API for tools not listed in tools/list. " + + "Returns each match's name, description and inputSchema. " + + "Use when no listed tool fits — then run it with call_tool.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "What you want to do, in plain language.", + }, + limit: { + type: "number", + description: "Max results (default 10, max 25).", + }, + }, + required: ["query"], + }, + }, + { + name: "call_tool", + description: + "Execute any ChatbotX tool by name, including ones not in tools/list.", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Exact tool name from search_tools.", + }, + arguments: { type: "object", description: "Arguments for that tool." }, + }, + required: ["name"], + }, + }, +] as const + +export const META_TOOL_NAMES: Record = { + search_tools: true, + call_tool: true, +} + +const DEFAULT_SEARCH_LIMIT = 10 +const MAX_SEARCH_LIMIT = 25 +// A name/description-token match is worth less than a whole-phrase match, +// and a name match outweighs a description match — a query naming the +// resource ("tags") should rank `tags_list` over an unrelated tool whose +// long description happens to mention tags in passing. +const NAME_TOKEN_WEIGHT = 2 +const DESCRIPTION_TOKEN_WEIGHT = 1 +const PHRASE_MATCH_BONUS = 3 + +function tokenize(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/g) ?? [] +} + +function scoreTool( + tool: DynamicTool, + queryTokens: string[], + queryPhrase: string, +): number { + const nameTokens = tokenize(tool.name) + const descriptionTokens = tokenize(tool.description) + + let score = 0 + for (const token of queryTokens) { + if (nameTokens.includes(token)) { + score += NAME_TOKEN_WEIGHT + } + if (descriptionTokens.includes(token)) { + score += DESCRIPTION_TOKEN_WEIGHT + } + } + + if ( + queryPhrase.length > 0 && + `${tool.name} ${tool.description}`.toLowerCase().includes(queryPhrase) + ) { + score += PHRASE_MATCH_BONUS + } + + return score +} + +/** + * Ranks every cached tool (not just the `visibility: "default"` set — + * that's the whole point) against the query and returns the top matches. + * Zero-scoring tools are dropped rather than padded in at the tail: an + * agent acting on a bad match is worse than an agent getting an empty list + * and rephrasing. + */ +export function searchTools(query: string, limit?: number): DynamicTool[] { + const queryPhrase = query.trim().toLowerCase() + const queryTokens = tokenize(query) + const cappedLimit = Math.min( + Math.max(limit ?? DEFAULT_SEARCH_LIMIT, 1), + MAX_SEARCH_LIMIT, + ) + + return getCachedTools() + .map((tool) => ({ tool, score: scoreTool(tool, queryTokens, queryPhrase) })) + .filter(({ score }) => score > 0) + .sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score + } + // Tie-break: a read fits more agent intents safely than a write, and + // a shorter name is usually the more general/canonical operation + // (`tags_list` over `contacts_list_tags`). + const aIsGet = a.tool.method === "GET" + const bIsGet = b.tool.method === "GET" + if (aIsGet !== bIsGet) { + return aIsGet ? -1 : 1 + } + return a.tool.name.length - b.tool.name.length + }) + .slice(0, cappedLimit) + .map(({ tool }) => tool) +} + +export function findToolByName(name: string): DynamicTool | undefined { + return getCachedTools().find((tool) => tool.name === name) +} + +/** + * `search_tools` handler — validates the raw MCP `arguments` object and + * returns each match's name/description/inputSchema as JSON text, the same + * shape a `tools/list` entry has, so an agent can go straight from a match + * to a `call_tool` invocation. + */ +export function handleSearchTools( + args: Record, +): ToolCallResult { + const query = args.query + if (typeof query !== "string" || query.trim().length === 0) { + return { + isError: true, + content: [ + { + type: "text", + text: "search_tools requires a non-empty 'query' string.", + }, + ], + } + } + const limit = typeof args.limit === "number" ? args.limit : undefined + + const matches = searchTools(query, limit).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })) + + return { + content: [{ type: "text", text: JSON.stringify(matches, null, 2) }], + } +} + +/** + * `call_tool` handler — looks up `name` against the *entire* cached tool + * list (no `visibility` filter; that filter only governs `tools/list`) and + * executes it exactly like a direct `tools/call` would. + */ +export async function handleCallTool( + args: Record, + apiKey: string, +): Promise { + const name = args.name + if (typeof name !== "string" || name.trim().length === 0) { + return { + isError: true, + content: [ + { type: "text", text: "call_tool requires a non-empty 'name' string." }, + ], + } + } + + const tool = findToolByName(name) + if (!tool) { + return { + isError: true, + content: [{ type: "text", text: `Unknown tool: ${name}` }], + } + } + + const toolArguments = + args.arguments && typeof args.arguments === "object" + ? (args.arguments as Record) + : {} + + return await executeTool(tool, toolArguments, apiKey) +} diff --git a/apps/mcp-server/src/server/sse-server.ts b/apps/mcp-server/src/server/sse-server.ts index b4bbe9433e..6e181c9601 100644 --- a/apps/mcp-server/src/server/sse-server.ts +++ b/apps/mcp-server/src/server/sse-server.ts @@ -10,12 +10,25 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { env } from "../env" import type { CreateMcpServerOptions } from "./create-mcp-server" +/** + * Mutable holder for a session's current workspace token. The token + * captured at connect time (GET `/sse`, or POST `/messages` `initialize`) + * seeds `current`; every later request routed to the same session can + * overwrite it if — and only if — that request itself carries a token (see + * `updateApiKeyStateFromRequest`). `createMcpServer`'s `getApiKey` reads + * `current` lazily on every tool call, so a token rotated or swapped + * mid-session takes effect on the next call without a reconnect. + */ +type ApiKeyState = { current: string } + type SseSession = { + apiKeyState: ApiKeyState server: McpServer transport: StreamableHTTPServerTransport } type LegacySseSession = { + apiKeyState: ApiKeyState server: McpServer transport: SSEServerTransport } @@ -25,7 +38,9 @@ const legacySseSessions = new Map() const apiTokenHeaderNames = ["x-workspace-token", "x-chatbo-token"] as const -const resolveHeaderValue = (value: string | string[] | undefined): string => { +export const resolveHeaderValue = ( + value: string | string[] | undefined, +): string => { if (typeof value === "string") { return value.trim() } @@ -42,7 +57,9 @@ const resolveHeaderValue = (value: string | string[] | undefined): string => { return "" } -const getApiTokenFromRequest = (req: IncomingMessage): string | undefined => { +export const getApiTokenFromRequest = ( + req: IncomingMessage, +): string | undefined => { const url = new URL(req.url ?? "", "http://localhost") const urlToken = ( url.searchParams.get("workspace_token") ?? url.searchParams.get("token") @@ -59,13 +76,33 @@ const getApiTokenFromRequest = (req: IncomingMessage): string | undefined => { } } -const makeGetApiKey = (req: IncomingMessage): (() => string) => { - const token = getApiTokenFromRequest(req) || env.CHATBOTX_API_KEY - return () => token +export const makeApiKeyState = (req: IncomingMessage): ApiKeyState => ({ + current: getApiTokenFromRequest(req) || env.CHATBOTX_API_KEY, +}) + +/** + * Priority order is unchanged from connect time + * (`?workspace_token=`/`?token=` → `x-workspace-token` → `x-chatbo-token`) — + * only applied per-request instead of once. A request that carries no token + * of its own (e.g. a bare Streamable HTTP GET for server-initiated + * messages) leaves `state.current` as it was, so the connect-time or + * previously-overwritten token keeps serving until a request explicitly + * supplies a new one. + */ +export const updateApiKeyStateFromRequest = ( + state: ApiKeyState, + req: IncomingMessage, +): void => { + const token = getApiTokenFromRequest(req) + if (token) { + state.current = token + } } +const getApiKeyFromState = (state: ApiKeyState) => (): string => state.current + const enableCors = (res: ServerResponse): void => { - res.setHeader("Access-Control-Allow-Origin", "*") + res.setHeader("Access-Control-Allow-Origin", env.CHATBOTX_MCP_CORS_ORIGIN) res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS") res.setHeader("Access-Control-Allow-Headers", "*") } @@ -142,12 +179,19 @@ const handleSseRequest = async ( // No session ID → old SSE protocol (Claude Desktop, Claude CLI -t sse) if (!sessionId) { - const server = createMcpServer({ getApiKey: makeGetApiKey(req) }) + const apiKeyState = makeApiKeyState(req) + const server = createMcpServer({ + getApiKey: getApiKeyFromState(apiKeyState), + }) const transport = new SSEServerTransport( env.CHATBOTX_MCP_MESSAGES_PATH, res, ) - legacySseSessions.set(transport.sessionId, { server, transport }) + legacySseSessions.set(transport.sessionId, { + apiKeyState, + server, + transport, + }) res.on("close", () => legacySseSessions.delete(transport.sessionId)) await server.connect(transport) return @@ -160,6 +204,7 @@ const handleSseRequest = async ( return } + updateApiKeyStateFromRequest(session.apiKeyState, req) setSessionIdHeader(req, sessionId) await session.transport.handleRequest(req, res) } @@ -188,6 +233,7 @@ const handleMessagesRequest = async ( if (sessionId) { const streamableSession = sseSessions.get(sessionId) if (streamableSession) { + updateApiKeyStateFromRequest(streamableSession.apiKeyState, req) setSessionIdHeader(req, sessionId) await streamableSession.transport.handleRequest(req, res, parsedBody) return @@ -195,6 +241,7 @@ const handleMessagesRequest = async ( const legacySession = legacySseSessions.get(sessionId) if (legacySession) { + updateApiKeyStateFromRequest(legacySession.apiKeyState, req) await legacySession.transport.handlePostMessage(req, res, parsedBody) return } @@ -212,13 +259,20 @@ const handleMessagesRequest = async ( return } + const apiKeyState = makeApiKeyState(req) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (initializedSessionId) => { - sseSessions.set(initializedSessionId, { server, transport }) + sseSessions.set(initializedSessionId, { + apiKeyState, + server, + transport, + }) }, }) - const server = createMcpServer({ getApiKey: makeGetApiKey(req) }) + const server = createMcpServer({ + getApiKey: getApiKeyFromState(apiKeyState), + }) transport.onclose = () => { const activeSessionId = transport.sessionId diff --git a/apps/mcp-server/src/token-introspection.ts b/apps/mcp-server/src/token-introspection.ts new file mode 100644 index 0000000000..0ac49724b8 --- /dev/null +++ b/apps/mcp-server/src/token-introspection.ts @@ -0,0 +1,57 @@ +import { env } from "./env" + +export type TokenIntrospection = { + workspaceId: string + permission: "read_only" | "full" + scopes: string[] | null +} + +/** + * Cached by the raw token *value*, never by session — a session's token can + * change mid-connection (see `sse-server.ts`'s per-request resolver), so + * caching by session id would serve a stale/wrong scope set after a token + * swap. Module-level, same pattern as `openapi-loader.ts`'s tool cache. + */ +const introspectionCache = new Map< + string, + { data: TokenIntrospection; fetchedAtMs: number } +>() + +/** + * Resolves a workspace token's scopes/permission via `GET /v1/token`, for + * scope-based `tools/list` filtering (P2.3). Returns `null` on any failure + * (network error, non-2xx, malformed body) — callers must fail OPEN (skip + * scope filtering, not hide every tool) on `null`: enforcement of scope + * still happens server-side on the actual call; this is a `tools/list` + * display concern only. + */ +export async function introspectToken( + apiKey: string, +): Promise { + const cached = introspectionCache.get(apiKey) + if (cached && Date.now() - cached.fetchedAtMs < env.CHATBOTX_SPEC_TTL_MS) { + return cached.data + } + + try { + const response = await fetch(`${env.CHATBOTX_API_URL}/v1/token`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }) + if (!response.ok) { + return null + } + const data = (await response.json()) as TokenIntrospection + introspectionCache.set(apiKey, { data, fetchedAtMs: Date.now() }) + return data + } catch (error) { + console.error( + `Token introspection failed, tools/list will not be scope-filtered: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + return null + } +} diff --git a/packages/business/package.json b/packages/business/package.json index 3987d58546..78b97698ba 100644 --- a/packages/business/package.json +++ b/packages/business/package.json @@ -9,6 +9,7 @@ "./ads-conversion/schema": "./src/ads-conversion/schema.ts", "./audit": "./src/audit/index.ts", "./branding": "./src/platform/branding.ts", + "./capabilities": "./src/capabilities/index.ts", "./contact-custom-field": "./src/contact-custom-field/index.ts", "./contact-custom-field-value": "./src/contact-custom-field/value-service.ts", "./contact-inbox": "./src/contact-inbox/index.ts", diff --git a/packages/business/src/capabilities/index.ts b/packages/business/src/capabilities/index.ts new file mode 100644 index 0000000000..9376fea807 --- /dev/null +++ b/packages/business/src/capabilities/index.ts @@ -0,0 +1 @@ +export * from "./service" diff --git a/packages/business/src/capabilities/service.ts b/packages/business/src/capabilities/service.ts new file mode 100644 index 0000000000..944cb4f3ac --- /dev/null +++ b/packages/business/src/capabilities/service.ts @@ -0,0 +1,316 @@ +import type { + FlowAuthoringContext, + TemplateComponent, + WaTemplateParams, +} from "@chatbotx.io/flow-config" +import { + extractTemplateParams, + waitStepDelayUnits, +} from "@chatbotx.io/flow-config" +import { channelTypes } from "@chatbotx.io/utils/channel" +import { aiAgentService } from "../ai-agent/service" +import { botFieldService } from "../bot-field/service" +import { customFieldService } from "../custom-field/service" +import { flowService } from "../flow/service" +import { inboxService } from "../inbox/service" +import { sequenceService } from "../sequence/service" +import { tagService } from "../tag/service" +import { whatsappMessageTemplateService } from "../whatsapp-message-template/service" + +/** + * Caps every list this service gathers. This output is fed straight into an + * LLM's context window (P2.1's design constraint, not a DB/perf one) — a + * workspace with thousands of tags or custom fields must never blow up the + * response; an agent that needs more than this can page through the + * resource's own list endpoint (`tags.list`, `customFields.list`, ...). + */ +const CAPABILITIES_LIST_LIMIT = 200 + +export const CAPABILITIES_INCLUDES = [ + "inboxes", + "templates", + "customFields", + "botFields", + "tags", + "aiAgents", + "sequences", + "flows", + "flowSpec", +] as const +export type CapabilitiesInclude = (typeof CAPABILITIES_INCLUDES)[number] + +// The default set returned when `include` is omitted — the flow-authoring +// essentials (P1.2's `FlowAuthoringContext` resolves against exactly these +// names). `aiAgents` is left out of the default: it's rarely needed to build +// a flow and the same information is one `ai_agents_list` call away. +const DEFAULT_INCLUDES: readonly CapabilitiesInclude[] = [ + "inboxes", + "templates", + "customFields", + "botFields", + "tags", + "sequences", + "flows", + "flowSpec", +] + +export type CapabilitiesInbox = { id: string; name: string; channel: string } +export type CapabilitiesTemplate = { + id: string + name: string + language: string + status: string + params: WaTemplateParams +} +export type CapabilitiesField = { id: string; name: string; type: string } +export type CapabilitiesNamedEntity = { id: string; name: string } +export type CapabilitiesFlowSpecStepType = { type: string; description: string } +export type CapabilitiesFlowSpec = { + stepTypes: CapabilitiesFlowSpecStepType[] + waitUnits: string[] + channels: string[] +} + +export type CapabilitiesResponse = { + inboxes?: CapabilitiesInbox[] + templates?: CapabilitiesTemplate[] + customFields?: CapabilitiesField[] + botFields?: CapabilitiesField[] + tags?: CapabilitiesNamedEntity[] + aiAgents?: CapabilitiesNamedEntity[] + sequences?: CapabilitiesNamedEntity[] + flows?: CapabilitiesNamedEntity[] + flowSpec?: CapabilitiesFlowSpec +} + +// Mirrors the `.describe()` text on each `flowStepSpecSchema` member in +// `@chatbotx.io/flow-config`'s `authoring/spec-schema.ts` — kept as a short, +// hand-written summary here rather than derived from the zod schema, since +// this list is meant to be skimmed inline in `capabilities.get`'s response, +// while `GET /v1/schemas/flow-spec` (P2.2) is the full, authoritative JSON +// Schema for actually authoring a step. +const FLOW_SPEC_STEP_TYPES: CapabilitiesFlowSpecStepType[] = [ + { + type: "send", + description: + "Send one text/image/file message, optionally with up to 3 quick-reply buttons.", + }, + { + type: "sendTemplate", + description: "Send an existing WhatsApp message template by name.", + }, + { type: "wait", description: "Pause the flow for a fixed duration." }, + { + type: "branch", + description: + "Split the flow by contact-filter-style conditions (see contacts.listFilterFields).", + }, + { + type: "action", + description: + "Perform a workspace action: addTags, removeTags, setCustomField, assignConversation, or archiveConversation.", + }, + { + type: "startFlow", + description: "Start another flow for the contact, by name.", + }, + { + type: "addNote", + description: "Add an internal note to the conversation.", + }, + { + type: "goto", + description: + "Jump to an already-defined step (by its `id`) instead of continuing linearly. Must be the last step in its list.", + }, +] + +async function listInboxes(workspaceId: string): Promise { + const { data } = await inboxService.list({ + workspaceId, + perPage: CAPABILITIES_LIST_LIMIT, + }) + return data.map((inbox) => ({ + id: inbox.id, + name: inbox.name, + channel: inbox.channel, + })) +} + +async function listTemplates( + workspaceId: string, +): Promise { + const templates = await whatsappMessageTemplateService.list({ + where: { workspaceId }, + }) + return templates.slice(0, CAPABILITIES_LIST_LIMIT).map((template) => ({ + id: template.id, + name: template.name, + language: template.language, + status: template.status, + params: extractTemplateParams(template.components as TemplateComponent[]), + })) +} + +async function listCustomFields( + workspaceId: string, +): Promise { + const { data } = await customFieldService.list({ + workspaceId, + perPage: CAPABILITIES_LIST_LIMIT, + }) + return data.map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + })) +} + +async function listBotFields( + workspaceId: string, +): Promise { + const { data } = await botFieldService.list({ + workspaceId, + perPage: CAPABILITIES_LIST_LIMIT, + }) + return data.map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + })) +} + +async function listTags( + workspaceId: string, +): Promise { + const tags = await tagService.listActive({ workspaceId }) + return tags.slice(0, CAPABILITIES_LIST_LIMIT) +} + +async function listAiAgents( + workspaceId: string, +): Promise { + const { data } = await aiAgentService.listAIAgents({ + workspaceId, + page: 1, + perPage: CAPABILITIES_LIST_LIMIT, + sort: [], + }) + return data.map((agent) => ({ id: agent.id, name: agent.name })) +} + +async function listSequences( + workspaceId: string, +): Promise { + const { data } = await sequenceService.list({ + workspaceId, + perPage: CAPABILITIES_LIST_LIMIT, + }) + return data.map((sequence) => ({ id: sequence.id, name: sequence.name })) +} + +async function listFlows( + workspaceId: string, +): Promise { + const { data } = await flowService.list({ + workspaceId, + perPage: CAPABILITIES_LIST_LIMIT, + }) + return data.map((flow) => ({ id: flow.id, name: flow.name })) +} + +function getFlowSpecCapabilities(): CapabilitiesFlowSpec { + return { + stepTypes: FLOW_SPEC_STEP_TYPES, + waitUnits: [...waitStepDelayUnits.options], + channels: [...channelTypes.options], + } +} + +const CAPABILITY_LOADERS: { + [K in CapabilitiesInclude]: ( + workspaceId: string, + ) => Promise +} = { + inboxes: listInboxes, + templates: listTemplates, + customFields: listCustomFields, + botFields: listBotFields, + tags: listTags, + aiAgents: listAiAgents, + sequences: listSequences, + flows: listFlows, + flowSpec: (_workspaceId: string) => + Promise.resolve(getFlowSpecCapabilities()), +} + +/** + * Workspace capability discovery for MCP agents (P2.1) — the same shape of + * problem `listContactFilterFieldsForAPI` already solves for contact + * filters: gather every named workspace entity an agent needs to reference + * by id, in parallel, compact. Reused directly by both `GET /v1/capabilities` + * (P2.2) and the flow-spec compiler's `FlowAuthoringContext` (P1.2/P1.3) — + * the latter via `getFlowAuthoringContext` below, so the two never drift on + * what a "known template/flow/tag/custom field" is. + */ +export async function getCapabilities(props: { + workspaceId: string + include?: readonly CapabilitiesInclude[] +}): Promise { + const { workspaceId } = props + const includes = props.include ?? DEFAULT_INCLUDES + + const entries = await Promise.all( + includes.map( + async (include) => + [include, await CAPABILITY_LOADERS[include](workspaceId)] as const, + ), + ) + + return Object.fromEntries(entries) as CapabilitiesResponse +} + +/** + * The exact reference maps `compileFlowSpec` (`@chatbotx.io/flow-config`) + * needs to resolve DSL names — fetched directly (independent of the public + * `include` filter above) since a flow-spec compile always needs every one + * of these, regardless of what a `capabilities.get` caller asked to see. + */ +export async function getFlowAuthoringContext( + workspaceId: string, +): Promise { + const [inboxes, templates, tags, customFields, flows] = await Promise.all([ + listInboxes(workspaceId), + listTemplates(workspaceId), + listTags(workspaceId), + listCustomFields(workspaceId), + listFlows(workspaceId), + ]) + + return { + templatesByName: new Map( + templates.map((template) => [ + template.name, + { + id: template.id, + language: template.language, + status: template.status, + }, + ]), + ), + inboxesByName: new Map( + inboxes.map((inbox) => [ + inbox.name, + { id: inbox.id, channel: inbox.channel }, + ]), + ), + tagsByName: new Map(tags.map((tag) => [tag.name, { id: tag.id }])), + customFieldsByName: new Map( + customFields.map((field) => [ + field.name, + { id: field.id, type: field.type }, + ]), + ), + flowsByName: new Map(flows.map((flow) => [flow.name, { id: flow.id }])), + } +} diff --git a/packages/flow-config/__tests__/authoring/compile.test.ts b/packages/flow-config/__tests__/authoring/compile.test.ts new file mode 100644 index 0000000000..76c57943dc --- /dev/null +++ b/packages/flow-config/__tests__/authoring/compile.test.ts @@ -0,0 +1,512 @@ +import { describe, expect, test } from "vitest" +import { z } from "zod" +import { + compileFlowSpec, + edgeSchema, + type FlowAuthoringContext, + FlowAuthoringException, + type FlowSpec, + type FlowStepSpec, + flowVersionSchema, + nodeTypeSchema, + parseFlowExport, + refineStepsByChannel, + stepTypes, +} from "../../src" + +const emptyCtx: FlowAuthoringContext = { + templatesByName: new Map(), + inboxesByName: new Map(), + tagsByName: new Map(), + customFieldsByName: new Map(), + flowsByName: new Map(), +} + +const ctx: FlowAuthoringContext = { + templatesByName: new Map([ + ["welcome_promo", { id: "1001", language: "en", status: "approved" }], + ]), + inboxesByName: new Map(), + tagsByName: new Map(), + customFieldsByName: new Map([["Plan", { id: "1002", type: "text" }]]), + flowsByName: new Map([["Nurture", { id: "1003" }]]), +} + +const spec = ( + steps: FlowStepSpec[], + overrides: Partial = {}, +): FlowSpec => ({ + formatVersion: 1, + name: "Test flow", + steps, + ...overrides, +}) + +/** Asserts the compiled graph is schema-valid exactly as publish requires. */ +function expectPublishable(nodes: unknown, edges: unknown): void { + const nodesResult = z + .array(flowVersionSchema) + .superRefine(refineStepsByChannel) + .safeParse(nodes) + expect(nodesResult.success, JSON.stringify(nodesResult.error?.issues)).toBe( + true, + ) + const edgesResult = z.array(edgeSchema).safeParse(edges) + expect(edgesResult.success, JSON.stringify(edgesResult.error?.issues)).toBe( + true, + ) +} + +describe("compileFlowSpec — one node per step type", () => { + test("send (text)", () => { + const compiled = compileFlowSpec( + spec([{ type: "send", text: "Hello!" }]), + emptyCtx, + ) + expect(compiled.nodes).toHaveLength(1) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.sendMessage) + expect(compiled.startNodeId).toBe(node?.id) + expect(node?.data.isStartNode).toBe(true) + if (node?.type === "sendMessage") { + expect(node.data.details.steps[0]).toMatchObject({ + stepType: stepTypes.enum.sendText, + text: "Hello!", + }) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("send (image)", () => { + const compiled = compileFlowSpec( + spec([{ type: "send", imageUrl: "https://example.com/a.png" }]), + emptyCtx, + ) + const node = compiled.nodes[0] + if (node?.type === "sendMessage") { + expect(node.data.details.steps[0]).toMatchObject({ + stepType: stepTypes.enum.sendImage, + url: "https://example.com/a.png", + }) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("send (file)", () => { + const compiled = compileFlowSpec( + spec([{ type: "send", fileUrl: "https://example.com/a.pdf" }]), + emptyCtx, + ) + const node = compiled.nodes[0] + if (node?.type === "sendMessage") { + expect(node.data.details.steps[0]).toMatchObject({ + stepType: stepTypes.enum.sendFile, + url: "https://example.com/a.pdf", + }) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("sendTemplate resolves the template by name", () => { + const compiled = compileFlowSpec( + spec([{ type: "sendTemplate", templateName: "welcome_promo" }]), + ctx, + ) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.sendMessage) + if (node?.type === "sendMessage") { + expect(node.data.details.beforeStep.channel).toBe("whatsapp") + expect(node.data.details.steps[0]).toMatchObject({ + stepType: stepTypes.enum.sendWaTemplateMessage, + template: { id: "1001", name: "welcome_promo", language: "en" }, + }) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("sendTemplate reports an unknown template with candidates", () => { + try { + compileFlowSpec( + spec([{ type: "sendTemplate", templateName: "welcom_promo" }]), + ctx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.path).toBe("steps[0].templateName") + expect(authoringError?.code).toBe("unknownTemplate") + expect(authoringError?.candidates).toContain("welcome_promo") + } + }) + + test("wait", () => { + const compiled = compileFlowSpec( + spec([{ type: "wait", duration: 2, unit: "hours" }]), + emptyCtx, + ) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.wait) + if (node?.type === "wait") { + expect(node.data.details.steps[0]).toMatchObject({ + duration: 2, + unit: "hours", + }) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test.each([ + ["addTags", { tagNames: ["VIP"] }, stepTypes.enum.addContactTag], + ["removeTags", { tagNames: ["VIP"] }, stepTypes.enum.removeContactTag], + [ + "setCustomField", + { customFieldName: "Plan", value: "premium" }, + stepTypes.enum.setCustomField, + ], + [ + "assignConversation", + { assigneeId: "user-1" }, + stepTypes.enum.assignConversation, + ], + ["archiveConversation", {}, stepTypes.enum.archiveConversation], + ] as const)("action %s compiles to a performAction node", (action, extra, expectedStepType) => { + const compiled = compileFlowSpec( + spec([{ type: "action", action, ...extra } as FlowStepSpec]), + ctx, + ) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.performAction) + if (node?.type === "performAction") { + expect(node.data.details.steps[0]?.stepType).toBe(expectedStepType) + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("startFlow resolves the target flow by name", () => { + const compiled = compileFlowSpec( + spec([{ type: "startFlow", flowName: "Nurture" }]), + ctx, + ) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.startFlow) + if (node?.type === "startFlow") { + expect(node.data.details.beforeStep.flowId).toBe("1003") + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("startFlow reports an unknown flow with candidates", () => { + try { + compileFlowSpec(spec([{ type: "startFlow", flowName: "Nurtur" }]), ctx) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.path).toBe("steps[0].flowName") + expect(authoringError?.code).toBe("unknownFlow") + expect(authoringError?.candidates).toContain("Nurture") + } + }) + + test("addNote", () => { + const compiled = compileFlowSpec( + spec([{ type: "addNote", note: "Called back" }]), + emptyCtx, + ) + const node = compiled.nodes[0] + expect(node?.type).toBe(nodeTypeSchema.enum.addNotes) + if (node?.type === "addNotes") { + expect(node.data.details.beforeStep.text).toBe("Called back") + } + expectPublishable(compiled.nodes, compiled.edges) + }) + + test("branch compiles to a condition node with cases and otherwise", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "branch", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "has email" }], + }, + ], + otherwise: [{ type: "addNote", note: "no email" }], + }, + ]), + emptyCtx, + ) + expect(compiled.nodes).toHaveLength(3) + const branchNode = compiled.nodes[0] + expect(branchNode?.type).toBe(nodeTypeSchema.enum.condition) + if (branchNode?.type === "condition") { + const conditionStep = branchNode.data.details.steps[0] + expect(conditionStep?.cases).toHaveLength(1) + expect(conditionStep?.cases[0]?.conditions[0]).toMatchObject({ + field: "email", + operator: "isNotEmpty", + }) + const caseEdge = compiled.edges.find( + (edge) => edge.sourceHandle === conditionStep?.cases[0]?.id, + ) + const otherwiseEdge = compiled.edges.find( + (edge) => edge.sourceHandle === conditionStep?.otherwiseId, + ) + expect(caseEdge).toBeDefined() + expect(otherwiseEdge).toBeDefined() + } + expectPublishable(compiled.nodes, compiled.edges) + }) +}) + +describe("compileFlowSpec — a realistic multi-step flow", () => { + test("send → wait 1h → branch → sendTemplate", () => { + const compiled = compileFlowSpec( + spec( + [ + { type: "send", text: "Hi! Thanks for reaching out." }, + { type: "wait", duration: 1, unit: "hours" }, + { + type: "branch", + cases: [ + { + when: [{ field: "country", operator: "eq", value: "US" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "sendTemplate", templateName: "welcome_promo" }], + }, + ], + }, + ], + { channel: "whatsapp" }, + ), + ctx, + ) + + expect(compiled.nodes.map((node) => node.type)).toEqual([ + "sendMessage", + "wait", + "condition", + "sendMessage", + ]) + // send -> wait -> branch chained by Continue edges (source === sourceHandle === node id). + const [sendNode, waitNode, branchNode] = compiled.nodes + expect( + compiled.edges.find( + (edge) => + edge.source === sendNode?.id && edge.sourceHandle === sendNode?.id, + )?.target, + ).toBe(waitNode?.id) + expect( + compiled.edges.find( + (edge) => + edge.source === waitNode?.id && edge.sourceHandle === waitNode?.id, + )?.target, + ).toBe(branchNode?.id) + + expectPublishable(compiled.nodes, compiled.edges) + }) +}) + +describe("compileFlowSpec — button routing", () => { + test("a routed button writes both the node's beforeStep and a matching edge", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "send", + text: "Want a discount?", + buttons: [ + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + { text: "Yes", then: [{ type: "addNote", note: "said yes" }] }, + { text: "No" }, + ], + }, + ]), + emptyCtx, + ) + + const sendNode = compiled.nodes[0] + expect(sendNode?.type).toBe("sendMessage") + if (sendNode?.type !== "sendMessage") { + throw new Error("expected sendMessage node") + } + const [yesButton, noButton] = sendNode.data.details.steps[0]?.buttons ?? [] + const noteNode = compiled.nodes[1] + + // Routed button: beforeStep points at the new node... + expect(yesButton?.buttonType).toBe("startAnotherNode") + if (yesButton?.buttonType === "startAnotherNode") { + expect(yesButton.beforeStep.nodeId).toBe(noteNode?.id) + } + // ...and an edge exists for the same handle, to the same target. + const edge = compiled.edges.find((e) => e.sourceHandle === yesButton?.id) + expect(edge).toMatchObject({ + source: sendNode.id, + target: noteNode?.id, + targetHandle: noteNode?.id, + }) + + // Unrouted button stays inert — no beforeStep, no edge. + expect(noButton?.buttonType).toBeNull() + expect( + compiled.edges.find((e) => e.sourceHandle === noButton?.id), + ).toBeUndefined() + + expectPublishable(compiled.nodes, compiled.edges) + }) +}) + +describe("compileFlowSpec — goto", () => { + test("jumps to an earlier step's node instead of creating a new one", () => { + const compiled = compileFlowSpec( + spec([ + { type: "addNote", id: "greet", note: "greeted" }, + { type: "wait", duration: 1, unit: "days" }, + { type: "goto", targetId: "greet" }, + ]), + emptyCtx, + ) + + // "goto" creates no node of its own. + expect(compiled.nodes).toHaveLength(2) + const [greetNode, waitNode] = compiled.nodes + const gotoEdge = compiled.edges.find( + (edge) => + edge.source === waitNode?.id && edge.sourceHandle === waitNode?.id, + ) + expect(gotoEdge?.target).toBe(greetNode?.id) + }) + + test("rejects goto as the first step", () => { + expect(() => + compileFlowSpec(spec([{ type: "goto", targetId: "x" }]), emptyCtx), + ).toThrow(FlowAuthoringException) + }) + + test("rejects a goto to an unknown step id", () => { + try { + compileFlowSpec( + spec([ + { type: "addNote", note: "hi" }, + { type: "goto", targetId: "does-not-exist" }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("invalidGotoTarget") + expect(authoringError?.path).toBe("steps[1].targetId") + } + }) +}) + +describe("compileFlowSpec — structural validation", () => { + test("reports an unreachable step after a terminal branch", () => { + try { + compileFlowSpec( + spec([ + { + type: "branch", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "x" }], + }, + ], + }, + { type: "addNote", note: "unreachable" }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("unreachableStep") + expect(authoringError?.path).toBe("steps[1]") + } + }) + + test("reports duplicate step ids anywhere in the spec", () => { + try { + compileFlowSpec( + spec([ + { type: "addNote", id: "dup", note: "a" }, + { type: "addNote", id: "dup", note: "b" }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const codes = (error as FlowAuthoringException).errors.map((e) => e.code) + expect(codes).toEqual(["duplicateStepId", "duplicateStepId"]) + } + }) +}) + +describe("compileFlowSpec — layout determinism", () => { + test("compiling the same spec twice produces identical positions", () => { + const buildSpec = () => + spec([ + { type: "send", text: "Hi" }, + { + type: "branch", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "a" }], + }, + ], + otherwise: [{ type: "addNote", note: "b" }], + }, + ]) + + const first = compileFlowSpec(buildSpec(), emptyCtx) + const second = compileFlowSpec(buildSpec(), emptyCtx) + + const positionsByOrder = (nodes: typeof first.nodes) => + nodes.map((node) => node.position) + expect(positionsByOrder(first.nodes)).toEqual( + positionsByOrder(second.nodes), + ) + }) +}) + +describe("compileFlowSpec — round-trip through the export schema", () => { + test("a compiled graph parses as a valid flow export", () => { + const compiled = compileFlowSpec( + spec([ + { type: "send", text: "Hi!" }, + { type: "wait", duration: 1, unit: "hours" }, + ]), + emptyCtx, + ) + + const result = parseFlowExport({ + formatVersion: 2, + exportedAt: new Date().toISOString(), + source: { workspaceId: "1", flowId: "1" }, + flows: [ + { + name: "Test flow", + active: true, + enableInInbox: false, + startNodeId: compiled.startNodeId, + nodes: compiled.nodes, + edges: compiled.edges, + }, + ], + customFields: {}, + botFields: {}, + }) + + expect(result.ok, result.ok ? undefined : result.reason).toBe(true) + }) +}) diff --git a/packages/flow-config/src/authoring/compile.ts b/packages/flow-config/src/authoring/compile.ts new file mode 100644 index 0000000000..cefdcb0083 --- /dev/null +++ b/packages/flow-config/src/authoring/compile.ts @@ -0,0 +1,693 @@ +import { createId } from "@chatbotx.io/utils" +import { addNotesNodeDefaultFn } from "../nodes/add-notes" +import { conditionNodeDefaultFn } from "../nodes/condition" +import type { EdgeSchema, FlowNode, FlowVersionSchema } from "../nodes/index" +import { performActionNodeDefaultFn } from "../nodes/perform-action" +import { sendMessageNodeDefaultFn } from "../nodes/send-message" +import { startFlowNodeDefaultFn } from "../nodes/start-flow" +import { waitNodeDefaultFn } from "../nodes/wait" +import { + applyRouteUpdatesInNodes, + type FlowRouteUpdate, +} from "../routable-handle" +import { addContactTagStepDefaultFn } from "../steps/add-contact-tag" +import { addNotesStepDefaultFn } from "../steps/add-notes" +import { archiveConversationStepDefaultFn } from "../steps/archive-conversation" +import { assignConversationStepDefaultFn } from "../steps/assign-conversation" +import { type ButtonStepProps, buttonStepDefaultFn } from "../steps/button" +import { chooseChannelStepDefaultFn } from "../steps/choose-channel" +import { conditionCaseDefaultFn } from "../steps/condition" +import { removeContactTagStepDefaultFn } from "../steps/remove-contact-tag" +import { sendFileStepDefaultFn } from "../steps/send-file" +import { sendImageStepDefaultFn } from "../steps/send-image" +import { sendTextStepDefaultFn } from "../steps/send-text" +import { sendWaTemplateMessageStepDefaultFn } from "../steps/send-wa-message-template" +import { + FieldOperationType, + setCustomFieldStepDefaultFn, +} from "../steps/set-custom-field" +import { startExternalFlowStepDefaultFn } from "../steps/start-external-flow" +import { waitStepDefaultFn } from "../steps/wait" +import type { FlowAuthoringError, FlowAuthoringErrorCode } from "./errors" +import { closestNames, FlowAuthoringException } from "./errors" +import { type LayoutPosition, layoutNodes } from "./layout" +import { + type FlowSpec, + type FlowStepSpec, + TERMINAL_STEP_TYPES, +} from "./spec-schema" + +/** + * Reference data the compiler resolves DSL names against. Populated from the + * capabilities service (P2.1) — kept as plain `Map`s so the compiler never + * touches the database directly (this package has no such dependency). + */ +export type FlowAuthoringContext = { + templatesByName: ReadonlyMap< + string, + { id: string; language: string; status: string } + > + inboxesByName: ReadonlyMap + tagsByName: ReadonlyMap + customFieldsByName: ReadonlyMap + flowsByName: ReadonlyMap +} + +export type CompiledFlow = { + startNodeId: string + nodes: FlowVersionSchema[] + edges: EdgeSchema[] +} + +type CompileState = { + nodes: FlowVersionSchema[] + edges: EdgeSchema[] + routeUpdates: FlowRouteUpdate[] + stepIdToNodeId: Map + errors: FlowAuthoringError[] + ctx: FlowAuthoringContext + channel?: string +} + +const addError = ( + state: CompileState, + path: string, + code: FlowAuthoringErrorCode, + message: string, + extra?: { hint?: string; candidates?: string[] }, +): void => { + state.errors.push({ path, code, message, ...extra }) +} + +/** Every explicit `id` a spec declares, recursively, for the pre-pass uniqueness check. */ +function collectExplicitStepIds( + steps: readonly FlowStepSpec[], + seen: Map, + pathPrefix: string, +): void { + steps.forEach((step, index) => { + const stepPath = `${pathPrefix}[${index}]` + if (step.type !== "goto" && step.id) { + const paths = seen.get(step.id) + if (paths) { + paths.push(stepPath) + } else { + seen.set(step.id, [stepPath]) + } + } + + if (step.type === "send") { + step.buttons?.forEach((button, buttonIndex) => { + if (button.then) { + collectExplicitStepIds( + button.then, + seen, + `${stepPath}.buttons[${buttonIndex}].then`, + ) + } + }) + } + if (step.type === "branch") { + step.cases.forEach((branchCase, caseIndex) => { + collectExplicitStepIds( + branchCase.then, + seen, + `${stepPath}.cases[${caseIndex}].then`, + ) + }) + if (step.otherwise) { + collectExplicitStepIds(step.otherwise, seen, `${stepPath}.otherwise`) + } + } + }) +} + +function assertNoDuplicateStepIds(spec: FlowSpec, state: CompileState): void { + const seen = new Map() + collectExplicitStepIds(spec.steps, seen, "steps") + for (const [id, paths] of seen) { + if (paths.length > 1) { + for (const path of paths) { + addError( + state, + path, + "duplicateStepId", + `Step id "${id}" is used by ${paths.length} steps; ids must be unique across the whole spec.`, + ) + } + } + } +} + +const registerNode = ( + state: CompileState, + specStepId: string | undefined, + node: FlowVersionSchema, +): string => { + state.nodes.push(node) + if (specStepId) { + state.stepIdToNodeId.set(specStepId, node.id) + } + return node.id +} + +const addContinueEdge = ( + state: CompileState, + source: string, + target: string, +): void => { + state.edges.push({ + id: createId(), + source, + sourceHandle: source, + target, + targetHandle: target, + }) +} + +const addHandleEdge = ( + state: CompileState, + source: string, + handleId: string, + target: string, +): void => { + state.edges.push({ + id: createId(), + source, + sourceHandle: handleId, + target, + targetHandle: target, + }) +} + +// ---- Per-step-type node builders ----------------------------------------- + +/** Compiles one quick-reply button: its node chain (if any), route, and edge. */ +function compileSendButton( + buttonSpec: { id?: string; text: string; then?: FlowStepSpec[] }, + sourceNodeId: string, + buttonPath: string, + state: CompileState, +): ButtonStepProps { + const button = buttonStepDefaultFn({ label: buttonSpec.text }) + if (!buttonSpec.then || buttonSpec.then.length === 0) { + return button + } + + const entryNodeId = compileChain(buttonSpec.then, buttonPath, state) + if (entryNodeId) { + state.routeUpdates.push({ + sourceNodeId, + handleId: button.id, + route: { targetNodeId: entryNodeId }, + }) + addHandleEdge(state, sourceNodeId, button.id, entryNodeId) + } + return button +} + +function compileSendStep( + step: Extract, + stepPath: string, + state: CompileState, +): string { + const node = sendMessageNodeDefaultFn({ + detailProps: { + beforeStep: chooseChannelStepDefaultFn({ + channel: state.channel ?? "omnichannel", + }), + }, + }) + // Registered before compiling nested button chains so `state.nodes` keeps + // encounter order (this node, then whatever its buttons route to) instead + // of the reverse — `node` is a reference, so mutating its `data.details` + // below still updates the array element already pushed. + const nodeId = registerNode(state, step.id, node) + + const buttons = (step.buttons ?? []).map((buttonSpec, buttonIndex) => + compileSendButton( + buttonSpec, + nodeId, + `${stepPath}.buttons[${buttonIndex}].then`, + state, + ), + ) + + const contentStep = (() => { + if (step.text) { + return { ...sendTextStepDefaultFn({ text: step.text }), buttons } + } + if (step.imageUrl) { + return { ...sendImageStepDefaultFn(), url: step.imageUrl, buttons } + } + return { ...sendFileStepDefaultFn(), url: step.fileUrl ?? "", buttons } + })() + + node.data.details.steps = [contentStep] + node.data.details.quickReplies = [] + + return nodeId +} + +function compileSendTemplateStep( + step: Extract, + stepPath: string, + state: CompileState, +): string | null { + const template = state.ctx.templatesByName.get(step.templateName) + if (!template) { + addError( + state, + `${stepPath}.templateName`, + "unknownTemplate", + `No WhatsApp template named "${step.templateName}" in this workspace.`, + { + hint: "Call capabilities.get and pick a name from its templates list.", + candidates: closestNames( + step.templateName, + state.ctx.templatesByName.keys(), + ), + }, + ) + return null + } + + const templateStep = sendWaTemplateMessageStepDefaultFn({ + template: { + id: template.id, + name: step.templateName, + language: template.language, + params: {}, + }, + }) + + const node = sendMessageNodeDefaultFn({ + detailProps: { + // A WA template step only ever sends over WhatsApp — pin the channel + // regardless of the flow's own default channel. + beforeStep: chooseChannelStepDefaultFn({ channel: "whatsapp" }), + }, + }) + node.data.details.steps = [templateStep] + + return registerNode(state, step.id, node) +} + +function compileWaitStep( + step: Extract, + _stepPath: string, + state: CompileState, +): string { + const waitStep = { + ...waitStepDefaultFn(), + duration: step.duration, + unit: step.unit, + } + const node = waitNodeDefaultFn({}) + node.data.details.steps = [waitStep] + return registerNode(state, step.id, node) +} + +function compileActionStep( + step: Extract, + _stepPath: string, + state: CompileState, +): string { + const actionStep = (() => { + switch (step.action) { + case "addTags": + return addContactTagStepDefaultFn({ tags: step.tagNames ?? [] }) + case "removeTags": + return { + ...removeContactTagStepDefaultFn(), + tags: step.tagNames ?? [], + } + case "setCustomField": + return { + ...setCustomFieldStepDefaultFn(), + inputFieldId: step.customFieldName ?? "", + operation: FieldOperationType.set, + value: step.value ?? "", + } + case "assignConversation": + return assignConversationStepDefaultFn({ + assignedId: step.assigneeId ?? "", + }) + case "archiveConversation": + return archiveConversationStepDefaultFn() + default: + return archiveConversationStepDefaultFn() + } + })() + + const node = performActionNodeDefaultFn({}) + node.data.details.steps = [actionStep] + return registerNode(state, step.id, node) +} + +function compileStartFlowStep( + step: Extract, + stepPath: string, + state: CompileState, +): string | null { + const targetFlow = state.ctx.flowsByName.get(step.flowName) + if (!targetFlow) { + addError( + state, + `${stepPath}.flowName`, + "unknownFlow", + `No flow named "${step.flowName}" in this workspace.`, + { + hint: "Call flows.list and pick a name from the results.", + candidates: closestNames(step.flowName, state.ctx.flowsByName.keys()), + }, + ) + return null + } + + const node = startFlowNodeDefaultFn({ + detailProps: { + beforeStep: startExternalFlowStepDefaultFn({ flowId: targetFlow.id }), + }, + }) + return registerNode(state, step.id, node) +} + +function compileAddNoteStep( + step: Extract, + _stepPath: string, + state: CompileState, +): string { + const node = addNotesNodeDefaultFn({ + detailProps: { beforeStep: addNotesStepDefaultFn({ text: step.note }) }, + }) + return registerNode(state, step.id, node) +} + +const BOT_FIELD_CONDITION_PREFIX = "botField:" +const CUSTOM_FIELD_CONDITION_PREFIX = "customField:" + +type BranchConditionSpec = { + field: string + operator: string + value?: string | string[] | [string, string] +} + +type CompiledCondition = { + field: string + operator: string + value?: BranchConditionSpec["value"] + customFieldId?: string +} + +function resolveBranchCondition( + condition: BranchConditionSpec, + path: string, + state: CompileState, +): CompiledCondition | null { + if (condition.field.startsWith(BOT_FIELD_CONDITION_PREFIX)) { + addError( + state, + `${path}.field`, + "invalidSpec", + "Bot field conditions are not supported by the flow-spec DSL yet — use a static field or 'customField:'.", + ) + return null + } + + if (condition.field.startsWith(CUSTOM_FIELD_CONDITION_PREFIX)) { + const name = condition.field.slice(CUSTOM_FIELD_CONDITION_PREFIX.length) + const customField = state.ctx.customFieldsByName.get(name) + if (!customField) { + addError( + state, + `${path}.field`, + "unknownCustomField", + `No custom field named "${name}" in this workspace.`, + { + hint: "Call contacts.listFilterFields and pick a custom field name from the results.", + candidates: closestNames(name, state.ctx.customFieldsByName.keys()), + }, + ) + return null + } + return { + field: "customField", + customFieldId: customField.id, + operator: condition.operator, + value: condition.value, + } + } + + return { + field: condition.field, + operator: condition.operator, + value: condition.value, + } +} + +function compileBranchStep( + step: Extract, + stepPath: string, + state: CompileState, +): string { + const node = conditionNodeDefaultFn({}) + const conditionStep = node.data.details.steps[0] + if (!conditionStep) { + throw new Error("conditionNodeDefaultFn produced no condition step") + } + // Registered before compiling case/otherwise chains — see the identical + // note on `compileSendStep`. + const nodeId = registerNode(state, step.id, node) + + conditionStep.cases = step.cases.map((branchCase, caseIndex) => { + const caseDefault = conditionCaseDefaultFn() + const casePath = `${stepPath}.cases[${caseIndex}]` + const conditions = branchCase.when + .map((condition, conditionIndex) => + resolveBranchCondition( + condition, + `${casePath}.when[${conditionIndex}]`, + state, + ), + ) + .filter((value): value is CompiledCondition => value !== null) + + const entryNodeId = compileChain(branchCase.then, `${casePath}.then`, state) + if (entryNodeId) { + addHandleEdge(state, nodeId, caseDefault.id, entryNodeId) + } + + return { + ...caseDefault, + operator: branchCase.match ?? "and", + conditions, + } + }) + + if (step.otherwise && step.otherwise.length > 0) { + const entryNodeId = compileChain( + step.otherwise, + `${stepPath}.otherwise`, + state, + ) + if (entryNodeId) { + addHandleEdge(state, nodeId, conditionStep.otherwiseId, entryNodeId) + } + } + + return nodeId +} + +function compileGotoStep( + step: Extract, + stepPath: string, + state: CompileState, +): string | null { + const targetNodeId = state.stepIdToNodeId.get(step.targetId) + if (!targetNodeId) { + addError( + state, + `${stepPath}.targetId`, + "invalidGotoTarget", + `"goto" targets step id "${step.targetId}", which is not an earlier step's id in this spec.`, + { + hint: "Set an explicit `id` on the step you want to jump to, earlier in the spec.", + }, + ) + return null + } + return targetNodeId +} + +function compileStep( + step: FlowStepSpec, + stepPath: string, + state: CompileState, +): string | null { + switch (step.type) { + case "send": + return compileSendStep(step, stepPath, state) + case "sendTemplate": + return compileSendTemplateStep(step, stepPath, state) + case "wait": + return compileWaitStep(step, stepPath, state) + case "branch": + return compileBranchStep(step, stepPath, state) + case "action": + return compileActionStep(step, stepPath, state) + case "startFlow": + return compileStartFlowStep(step, stepPath, state) + case "addNote": + return compileAddNoteStep(step, stepPath, state) + case "goto": + return compileGotoStep(step, stepPath, state) + default: + return null + } +} + +/** + * Compiles a step list into a chain of nodes wired by "Continue" edges + * (`sourceHandle` = the source node's own id, per the handle-id convention), + * and returns the entry point — the id of its first node, or (when the list + * opens with `goto`) the id of the existing node it jumps to. `null` only + * for an empty list. + */ +function compileChain( + steps: readonly FlowStepSpec[], + pathPrefix: string, + state: CompileState, +): string | null { + let previousNodeId: string | null = null + let previousStepWasTerminal = false + let entryNodeId: string | null = null + + steps.forEach((step, index) => { + const stepPath = `${pathPrefix}[${index}]` + const isTerminal = Boolean(TERMINAL_STEP_TYPES[step.type]) + + if (index < steps.length - 1 && isTerminal) { + addError( + state, + `${pathPrefix}[${index + 1}]`, + "unreachableStep", + `Step ${index + 2} of ${steps.length} can never run — "${step.type}" at step ${index + 1} ends its step list.`, + { + hint: "Move the following steps inside this step's own branch (e.g. a branch case's `then`) or delete them.", + }, + ) + } + + const nodeId = compileStep(step, stepPath, state) + if (nodeId !== null) { + if (entryNodeId === null) { + entryNodeId = nodeId + } + if (previousNodeId && !previousStepWasTerminal) { + addContinueEdge(state, previousNodeId, nodeId) + } + previousNodeId = nodeId + } + previousStepWasTerminal = isTerminal + }) + + return entryNodeId +} + +/** + * Applies a computed position and `isStartNode` flag while preserving the + * node's exact discriminated-union member type. A plain `{ ...node, ... }` + * spread over a `FlowVersionSchema` (a 9-member discriminated union) loses + * the correlation TS needs between `type` and the rest of the shape — this + * generic keeps `T` bound to the caller's already-narrowed member type + * instead of re-widening to the full union. + */ +function withLayoutPosition( + node: T, + position: LayoutPosition, + isStartNode: boolean, +): T { + return { ...node, position, data: { ...node.data, isStartNode } } +} + +/** + * Compiles a `flowSpecSchema`-shaped spec into `{ startNodeId, nodes, edges }` + * ready for `publishFlowSchema.parse` / `flowVersionService.publish`. + * + * Every node comes from its canonical `*NodeDefaultFn` so it can never drift + * from the builder's own defaults (position/measured are overwritten by + * `layoutNodes` afterward; everything else — `data`, default sub-steps — is + * exactly what the builder itself would create). Button routing is never + * hand-assembled: routes are collected as `FlowRouteUpdate`s and applied in + * one call to `applyRouteUpdatesInNodes`, the same helper the builder UI + * uses, so a future change to how routes are stored is picked up here for + * free. + * + * Throws `FlowAuthoringException` (never a partial result) when compilation + * hits any error — reference-name lookups, duplicate ids, or structural + * issues (an unreachable step, a `goto` to an unknown id). Every error found + * is collected before throwing, not just the first. + */ +export function compileFlowSpec( + spec: FlowSpec, + ctx: FlowAuthoringContext, +): CompiledFlow { + const state: CompileState = { + nodes: [], + edges: [], + routeUpdates: [], + stepIdToNodeId: new Map(), + errors: [], + ctx, + channel: spec.channel, + } + + assertNoDuplicateStepIds(spec, state) + + if (spec.steps[0]?.type === "goto") { + addError( + state, + "steps[0]", + "invalidFirstStep", + '"goto" cannot be the first step — there is no earlier step yet to jump from.', + ) + } + + const startNodeId = compileChain(spec.steps, "steps", state) + + if (state.errors.length > 0) { + throw new FlowAuthoringException(state.errors) + } + + if (!startNodeId) { + throw new FlowAuthoringException([ + { + path: "steps", + code: "compileFailed", + message: "Compilation produced no nodes.", + }, + ]) + } + + const routedNodes = applyRouteUpdatesInNodes( + state.nodes as unknown as FlowNode[], + state.routeUpdates, + ) as unknown as FlowVersionSchema[] + + const positions = layoutNodes( + routedNodes.map((node) => node.id), + state.edges, + startNodeId, + ) + + const nodes = routedNodes.map((node, index) => + withLayoutPosition( + node, + positions.get(node.id) ?? node.position, + index === 0 && node.id === startNodeId, + ), + ) + + return { startNodeId, nodes, edges: state.edges } +} diff --git a/packages/flow-config/src/authoring/errors.ts b/packages/flow-config/src/authoring/errors.ts new file mode 100644 index 0000000000..efe1b74542 --- /dev/null +++ b/packages/flow-config/src/authoring/errors.ts @@ -0,0 +1,144 @@ +import type { z } from "zod" + +/** + * Structured compiler diagnostics for the flow-spec DSL (P1.2). Deliberately + * NOT routed through `flowValidationCodes`/`resolveFlowValidationMessageKey` + * (`../validation-codes`) — that mechanism maps a fixed set of codes to a + * `messages.` i18n key across 20 locales for the builder UI. A + * flow-spec authoring error is API-consumer-facing (an agent, not a person + * reading the builder UI), so it is returned as plain structured data + * instead of paying that i18n tax for a growing, agent-only code set. + */ +export type FlowAuthoringErrorCode = + | "invalidSpec" + | "invalidFirstStep" + | "unreachableStep" + | "unknownTemplate" + | "unknownFlow" + | "unknownCustomField" + | "invalidGotoTarget" + | "duplicateStepId" + | "compileFailed" + +export type FlowAuthoringError = { + /** Spec-relative path, e.g. `steps[2].templateName` — never a compiled-node path. */ + path: string + code: FlowAuthoringErrorCode + message: string + hint?: string + candidates?: string[] +} + +export class FlowAuthoringException extends Error { + readonly errors: readonly FlowAuthoringError[] + + constructor(errors: readonly FlowAuthoringError[]) { + super( + errors.length > 0 + ? errors.map((error) => `${error.path}: ${error.message}`).join("; ") + : "Flow authoring failed", + ) + this.name = "FlowAuthoringException" + this.errors = errors + } +} + +const formatZodPathSegment = (acc: string, segment: PropertyKey): string => { + if (typeof segment === "number") { + return `${acc}[${segment}]` + } + const key = String(segment) + return acc.length === 0 ? key : `${acc}.${key}` +} + +/** + * Converts a `flowSpecSchema` parse failure straight into + * `FlowAuthoringError[]` — the issue paths are already spec-relative since + * they come from validating the spec itself, not a compiled node graph. + */ +export function zodErrorToFlowAuthoringErrors( + error: z.ZodError, + code: FlowAuthoringErrorCode = "invalidSpec", +): FlowAuthoringError[] { + return error.issues.map((issue) => ({ + path: issue.path.reduce(formatZodPathSegment, ""), + code, + message: issue.message, + })) +} + +// Small edit-distance algorithm — names here are short (workspace entity +// names), so the O(n*m) cost is negligible, and a real distance catches the +// single-character typos a prefix/substring check misses (e.g. +// "welcom_promo" vs "welcome_promo"). +function levenshteinDistance(a: string, b: string): number { + const rows = a.length + 1 + const cols = b.length + 1 + + const initialCell = (row: number, col: number): number => { + if (row === 0) { + return col + } + return col === 0 ? row : 0 + } + const distances: number[][] = Array.from({ length: rows }, (_, i) => + Array.from({ length: cols }, (_, j) => initialCell(i, j)), + ) + + for (let i = 1; i < rows; i++) { + for (let j = 1; j < cols; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + distances[i][j] = Math.min( + distances[i - 1][j] + 1, + distances[i][j - 1] + 1, + distances[i - 1][j - 1] + cost, + ) + } + } + + return distances[a.length][b.length] +} + +// A candidate qualifies when it's "close enough": either a prefix/substring +// match, or an edit distance small relative to name length (typo-tolerant +// without matching two genuinely unrelated short names). +const MAX_EDIT_DISTANCE_RATIO = 0.34 + +function nameSimilarity(target: string, candidate: string): number { + if (target === candidate) { + return Number.POSITIVE_INFINITY + } + if (candidate.startsWith(target) || target.startsWith(candidate)) { + return 1000 - Math.abs(candidate.length - target.length) + } + if (candidate.includes(target) || target.includes(candidate)) { + return 500 - Math.abs(candidate.length - target.length) + } + + const distance = levenshteinDistance(target, candidate) + const maxLength = Math.max(target.length, candidate.length) + const allowedDistance = Math.max( + 2, + Math.ceil(maxLength * MAX_EDIT_DISTANCE_RATIO), + ) + return distance <= allowedDistance ? maxLength - distance : 0 +} + +const MAX_CANDIDATES = 3 + +/** Nearest-name suggestions for an "unknown X" error's `candidates`. */ +export function closestNames( + target: string, + available: Iterable, +): string[] { + const targetLower = target.toLowerCase() + return [...available] + .map((name) => ({ + name, + score: nameSimilarity(targetLower, name.toLowerCase()), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_CANDIDATES) + .map(({ name }) => name) +} diff --git a/packages/flow-config/src/authoring/layout.ts b/packages/flow-config/src/authoring/layout.ts new file mode 100644 index 0000000000..3ebcd763fe --- /dev/null +++ b/packages/flow-config/src/authoring/layout.ts @@ -0,0 +1,88 @@ +// Node width/height mirror `defaultNodeData()`'s `measured` in `../nodes/base.ts` +// — every node type shares the same default footprint, so layout can use one +// fixed cell size instead of asking each node for its own. +const NODE_WIDTH = 288 +const NODE_HEIGHT = 100 +const COLUMN_GAP = 120 +const ROW_GAP = 80 +const COLUMN_WIDTH = NODE_WIDTH + COLUMN_GAP +const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP +const ORIGIN = { x: 100, y: 100 } + +export type LayoutEdge = { source: string; target: string } +export type LayoutPosition = { x: number; y: number } + +/** + * Deterministic column-by-depth, row-by-branch layout: BFS from the start + * node assigns each node's column to its BFS depth and its row to the order + * it was first reached within that depth. Same `nodeIds`/`edges`/`startNodeId` + * always produce the same positions — required for the compiler's golden + * snapshot tests, and generally desirable so re-compiling the same spec + * doesn't jitter node positions. + */ +export function layoutNodes( + nodeIds: readonly string[], + edges: readonly LayoutEdge[], + startNodeId: string, +): Map { + const childrenBySource = new Map() + for (const edge of edges) { + const children = childrenBySource.get(edge.source) + if (children) { + children.push(edge.target) + } else { + childrenBySource.set(edge.source, [edge.target]) + } + } + + const positions = new Map() + const rowCountByDepth = new Map() + + const place = (nodeId: string, depth: number): void => { + if (positions.has(nodeId)) { + return + } + const row = rowCountByDepth.get(depth) ?? 0 + rowCountByDepth.set(depth, row + 1) + positions.set(nodeId, { + x: ORIGIN.x + depth * COLUMN_WIDTH, + y: ORIGIN.y + row * ROW_HEIGHT, + }) + } + + // BFS from the start node — depth doubles as column index, and visiting + // order within a depth doubles as row index, matching "row by branch". + let frontier = [startNodeId] + let depth = 0 + const visited = new Set() + while (frontier.length > 0) { + const nextFrontier: string[] = [] + for (const nodeId of frontier) { + if (visited.has(nodeId)) { + continue + } + visited.add(nodeId) + place(nodeId, depth) + for (const child of childrenBySource.get(nodeId) ?? []) { + if (!visited.has(child)) { + nextFrontier.push(child) + } + } + } + frontier = nextFrontier + depth += 1 + } + + // A node unreachable from the start should not happen for a spec the + // compiler produced by construction, but every node still gets a position + // (appended after every reachable column) rather than silently missing one. + let fallbackDepth = depth + for (const nodeId of nodeIds) { + if (!positions.has(nodeId)) { + place(nodeId, fallbackDepth) + fallbackDepth += 1 + } + } + + return positions +} diff --git a/packages/flow-config/src/authoring/spec-schema.ts b/packages/flow-config/src/authoring/spec-schema.ts new file mode 100644 index 0000000000..71d92534b5 --- /dev/null +++ b/packages/flow-config/src/authoring/spec-schema.ts @@ -0,0 +1,388 @@ +import { channelTypes } from "@chatbotx.io/utils/channel" +import { z } from "zod" +import { waitStepDelayUnits } from "../steps/wait" + +/** + * The agent-facing flow DSL (P1.2 of the MCP Agent-First plan). A small, + * deliberately curated subset of the full node/step surface — the goal is an + * agent reliably building a *working* flow, not exposing every editor + * feature. Every field carries `.describe()`: this schema is also the source + * for `GET /v1/schemas/flow-spec` (P2.2), so the description IS the + * documentation an LLM sees. + * + * Hand-written recursive TS type first (`send.buttons[].then` and + * `branch.cases[].then`/`.otherwise` reference the step array itself) so + * `z.lazy` can close the cycle — zod cannot infer a recursive type from a + * schema that references itself before it exists. + */ +export type FlowStepSpec = + | { + type: "send" + id?: string + text?: string + imageUrl?: string + fileUrl?: string + buttons?: Array<{ id?: string; text: string; then?: FlowStepSpec[] }> + } + | { type: "sendTemplate"; id?: string; templateName: string } + | { + type: "wait" + id?: string + duration: number + unit: z.infer + } + | { + type: "branch" + id?: string + cases: Array<{ + id?: string + match?: "and" | "or" + when: Array<{ + field: string + operator: string + value?: string | string[] | [string, string] + }> + then: FlowStepSpec[] + }> + otherwise?: FlowStepSpec[] + } + | { + type: "action" + id?: string + action: + | "addTags" + | "removeTags" + | "setCustomField" + | "assignConversation" + | "archiveConversation" + tagNames?: string[] + customFieldName?: string + value?: string + assigneeId?: string + } + | { type: "startFlow"; id?: string; flowName: string } + | { type: "addNote"; id?: string; note: string } + | { type: "goto"; targetId: string } + +const stepIdField = z + .string() + .min(1) + .optional() + .describe( + "Stable id for this step. Omit to auto-generate one. Set it explicitly when a `goto` elsewhere needs to jump to this exact step.", + ) + +const sendButtonSpecSchema: z.ZodType<{ + id?: string + text: string + then?: FlowStepSpec[] +}> = z.lazy(() => + z.object({ + id: z.string().min(1).optional(), + text: z + .string() + .trim() + .min(1) + .max(20) + .describe("Button label shown to the contact (max 20 characters)."), + // DSL vocabulary ("steps to run then"), not an accidental thenable — the + // value is an array, never callable, so nothing ever treats this object + // as a Promise. + // biome-ignore lint/suspicious/noThenProperty: see comment above + then: z + .array(flowStepSpecSchema) + .optional() + .describe( + "Steps to run when the contact taps this button. Omitted or empty means the button has no follow-up.", + ), + }), +) + +const sendStepSpecSchema = z + .object({ + type: z.literal("send"), + id: stepIdField, + text: z + .string() + .trim() + .min(1) + .max(1000) + .optional() + .describe( + "Text message body. Exactly one of text/imageUrl/fileUrl is required.", + ), + imageUrl: z + .url() + .optional() + .describe( + "Image URL to send. Exactly one of text/imageUrl/fileUrl is required.", + ), + fileUrl: z + .url() + .optional() + .describe( + "File URL to send. Exactly one of text/imageUrl/fileUrl is required.", + ), + buttons: z + .array(sendButtonSpecSchema) + .max(3) + .optional() + .describe("Up to 3 quick-reply buttons attached to this message."), + }) + .describe( + "Sends one message (text, image, or file), optionally with quick-reply buttons.", + ) + .superRefine((data, ctx) => { + const kinds = [data.text, data.imageUrl, data.fileUrl].filter( + (value) => value !== undefined, + ) + if (kinds.length !== 1) { + ctx.addIssue({ + code: "custom", + message: "Exactly one of text, imageUrl, or fileUrl is required.", + path: [], + }) + } + }) + +const sendTemplateStepSpecSchema = z + .object({ + type: z.literal("sendTemplate"), + id: stepIdField, + templateName: z + .string() + .trim() + .min(1) + .describe( + "Name of an existing, approved WhatsApp message template (see `capabilities.get`'s `templates` list). Sent as-is, without dynamic variables.", + ), + }) + .describe("Sends an existing WhatsApp message template.") + +const waitStepSpecSchema = z + .object({ + type: z.literal("wait"), + id: stepIdField, + duration: z + .number() + .int() + .positive() + .describe("How long to wait, in `unit`s."), + unit: waitStepDelayUnits.describe("Unit for `duration`."), + }) + .describe("Pauses the flow for a fixed duration before continuing.") + +const branchConditionSpecSchema = z.object({ + field: z + .string() + .min(1) + .describe( + "A static field name from `GET /v1/contacts/filter-fields`, or `customField:` / `botField:` to reference a workspace custom/bot field by name (resolved automatically — use the exact name from `contacts.listFilterFields`).", + ), + operator: z + .string() + .min(1) + .describe( + "One of the operators `GET /v1/contacts/filter-fields` lists for this field.", + ), + value: z + .union([z.string(), z.array(z.string()), z.tuple([z.string(), z.string()])]) + .optional() + .describe( + "Comparison value. Omit for valueless operators (e.g. isEmpty/isNotEmpty). A two-element tuple is a between-range.", + ), +}) + +const branchCaseSpecSchema: z.ZodType<{ + id?: string + match?: "and" | "or" + when: Array<{ + field: string + operator: string + value?: string | string[] | [string, string] + }> + then: FlowStepSpec[] +}> = z.lazy(() => + z.object({ + id: z.string().min(1).optional(), + match: z + .enum(["and", "or"]) + .default("and") + .describe( + "Whether every ('and') or any ('or') condition in `when` must match.", + ), + when: z.array(branchConditionSpecSchema).min(1), + // DSL vocabulary, see the identical note on the button step's `then` + // above. + // biome-ignore lint/suspicious/noThenProperty: see comment above + then: z + .array(flowStepSpecSchema) + .min(1) + .describe("Steps to run when this case matches."), + }), +) + +const branchStepSpecSchema = z + .object({ + type: z.literal("branch"), + id: stepIdField, + cases: z.array(branchCaseSpecSchema).min(1), + otherwise: z + .array(z.lazy(() => flowStepSpecSchema)) + .optional() + .describe("Steps to run when no case matches."), + }) + .describe( + "Splits the flow by contact-filter-style conditions. Terminal within its step list — nothing may follow a `branch` at the same level; continue inside `cases[].then` / `otherwise` instead.", + ) + +const actionStepSpecSchema = z + .object({ + type: z.literal("action"), + id: stepIdField, + action: z + .enum([ + "addTags", + "removeTags", + "setCustomField", + "assignConversation", + "archiveConversation", + ]) + .describe("Which workspace action to perform."), + tagNames: z + .array(z.string().trim().min(1)) + .optional() + .describe("Tag names. Required for `addTags`/`removeTags`."), + customFieldName: z + .string() + .trim() + .min(1) + .optional() + .describe("Custom field name. Required for `setCustomField`."), + value: z + .string() + .optional() + .describe("Value to set. Required for `setCustomField`."), + assigneeId: z + .string() + .optional() + .describe( + "Workspace member id to assign the conversation to. Optional for `assignConversation`; omit to unassign.", + ), + }) + .describe( + "Performs a workspace side-effect: tag, custom field, or conversation action.", + ) + .superRefine((data, ctx) => { + if ( + (data.action === "addTags" || data.action === "removeTags") && + (!data.tagNames || data.tagNames.length === 0) + ) { + ctx.addIssue({ + code: "custom", + message: `action "${data.action}" requires a non-empty tagNames`, + path: ["tagNames"], + }) + } + if (data.action === "setCustomField") { + if (!data.customFieldName) { + ctx.addIssue({ + code: "custom", + message: 'action "setCustomField" requires customFieldName', + path: ["customFieldName"], + }) + } + if (data.value === undefined) { + ctx.addIssue({ + code: "custom", + message: 'action "setCustomField" requires value', + path: ["value"], + }) + } + } + }) + +const startFlowStepSpecSchema = z + .object({ + type: z.literal("startFlow"), + id: stepIdField, + flowName: z + .string() + .trim() + .min(1) + .describe( + "Name of another existing flow in this workspace (see `flows.list`).", + ), + }) + .describe( + "Starts another flow for the contact; this flow keeps running afterward (an ordinary step may follow).", + ) + +const addNoteStepSpecSchema = z + .object({ + type: z.literal("addNote"), + id: stepIdField, + note: z + .string() + .trim() + .min(1) + .max(1000) + .describe("Internal note text — never shown to the contact."), + }) + .describe("Adds an internal note to the conversation.") + +const gotoStepSpecSchema = z + .object({ + type: z.literal("goto"), + targetId: z + .string() + .min(1) + .describe( + "The `id` of an earlier step in this flow spec to jump to, instead of continuing linearly.", + ), + }) + .describe( + "Terminal — routes to an already-defined step instead of continuing. Must be the last step in its list.", + ) + +export const flowStepSpecSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + sendStepSpecSchema, + sendTemplateStepSpecSchema, + waitStepSpecSchema, + branchStepSpecSchema, + actionStepSpecSchema, + startFlowStepSpecSchema, + addNoteStepSpecSchema, + gotoStepSpecSchema, + ], +) + +/** Step `type`s that end their step list — nothing may follow them. */ +export const TERMINAL_STEP_TYPES: Record< + FlowStepSpec["type"], + true | undefined +> = { + send: undefined, + sendTemplate: undefined, + wait: undefined, + branch: true, + action: undefined, + startFlow: undefined, + addNote: undefined, + goto: true, +} + +export const flowSpecSchema = z.object({ + formatVersion: z.literal(1).describe("DSL format version. Always 1."), + name: z.string().trim().min(1).max(255).describe("Flow name."), + channel: channelTypes + .optional() + .describe("Channel this flow targets. Omit for any/omnichannel."), + steps: z + .array(flowStepSpecSchema) + .min(1) + .describe("Ordered steps executed from the flow's start node."), +}) +export type FlowSpec = z.infer diff --git a/packages/flow-config/src/index.ts b/packages/flow-config/src/index.ts index 06fee4bf2b..bfacf99b3b 100644 --- a/packages/flow-config/src/index.ts +++ b/packages/flow-config/src/index.ts @@ -1,4 +1,8 @@ // Export all definitions +export * from "./authoring/compile" +export * from "./authoring/errors" +export * from "./authoring/layout" +export * from "./authoring/spec-schema" export * from "./channel-rules/channel-step-refinement" export * from "./channel-rules/channel-validator" export * from "./channel-rules/media-step-rules" diff --git a/packages/flow-config/src/nodes/index.ts b/packages/flow-config/src/nodes/index.ts index b0ed7ed0ba..e5542fa6c5 100644 --- a/packages/flow-config/src/nodes/index.ts +++ b/packages/flow-config/src/nodes/index.ts @@ -13,7 +13,7 @@ import { splitTrafficNodeSchema } from "./split-traffic" import { startFlowNodeSchema } from "./start-flow" import { waitNodeSchema } from "./wait" -export const flowVersionSchema = z.union([ +export const flowVersionSchema = z.discriminatedUnion("type", [ sendMessageNodeSchema, startFlowNodeSchema, performActionNodeSchema, From 78c7e903679f4045a68a58e03932af581a6e05ed Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Mon, 14 Sep 2026 07:21:48 +0700 Subject: [PATCH 02/38] fix(mcp): address review findings on the mcp-agent-first surface Critical/Important fixes: - flows.publish/validate {spec} path: compileAndValidateSpec now runs publishFlowSchema (channel rules) against the compiled graph and remaps zod issues back to spec-relative paths, so a channel-rule violation returns a structured 422 instead of a raw 500. orpc.ts also gains a ZodError safety net. - action.setCustomField now resolves the field name via the same candidates-on-miss path as branch conditions, instead of passing the raw name through as inputFieldId. - mcp-server token introspection response is zod-validated instead of blindly cast, and the introspection cache evicts expired entries. - read-only token visibility now derives from the x-mcp.readOnlyHint annotation (contacts.search opts in) instead of a hard-coded tool-name allowlist. - META_TOOL_NAMES / NO_BODY_METHODS use Set.has() instead of `in` on a plain object, closing a prototype-chain lookup risk. Cleanup: - remove dead FlowAuthoringContext fields, schema aliases, and the hand-written flow-spec step-type table (now derived from the schema); un-export McpVisibility/DynamicToolAnnotations/TokenScopeIntrospection. - search_tools no longer requires an API key; its scoring precomputes per-tool tokens in a WeakMap; flow-spec JSON Schema is memoized. - strip stale plan-section references and fix inaccurate route/operation counts in comments and docs. Tests: new create-mcp-server.test.ts and capabilities-service.test.ts, plus expanded coverage in compile.test.ts, flows-public-api.test.ts, orpc-error-mapping.test.ts, token-introspection.test.ts, openapi-loader.test.ts, and public-spec-mcp.test.ts. --- .../__tests__/flows-public-api.test.ts | 62 ++++- .../__tests__/orpc-error-mapping.test.ts | 35 +++ .../builder/__tests__/public-spec-mcp.test.ts | 7 + .../__tests__/public-spec-operations.test.ts | 4 +- .../src/features/capabilities/api/public.ts | 20 +- .../src/features/contacts/api/public/crud.ts | 6 +- apps/builder/src/features/flows/api/public.ts | 13 +- .../flows/lib/compile-spec-to-graph.ts | 58 ++++ .../src/features/flows/schema/action.ts | 19 +- apps/builder/src/features/token/api/public.ts | 6 +- apps/builder/src/lib/orpc/mcp-annotations.ts | 6 +- apps/builder/src/orpc.ts | 14 +- apps/builder/src/routers/public.ts | 2 +- apps/mcp-server/README.md | 4 +- apps/mcp-server/SKILL.md | 2 +- .../__tests__/create-mcp-server.test.ts | 247 ++++++++++++++++++ apps/mcp-server/__tests__/meta-tools.test.ts | 5 +- .../__tests__/openapi-loader.test.ts | 17 +- .../__tests__/token-introspection.test.ts | 33 +++ apps/mcp-server/src/openapi-loader.ts | 25 +- .../src/server/create-mcp-server.ts | 12 +- apps/mcp-server/src/server/execute-tool.ts | 8 +- apps/mcp-server/src/server/meta-tools.ts | 36 ++- apps/mcp-server/src/server/sse-server.ts | 6 +- apps/mcp-server/src/token-introspection.ts | 41 ++- .../__tests__/capabilities-service.test.ts | 139 ++++++++++ packages/business/src/capabilities/service.ts | 107 +++----- .../__tests__/authoring/compile.test.ts | 99 ++++++- .../__tests__/authoring/spec-schema.test.ts | 27 ++ packages/flow-config/src/authoring/compile.ts | 116 +++++--- packages/flow-config/src/authoring/errors.ts | 22 +- packages/flow-config/src/authoring/layout.ts | 5 +- .../flow-config/src/authoring/spec-schema.ts | 62 ++--- packages/flow-config/src/index.ts | 1 - 34 files changed, 1002 insertions(+), 264 deletions(-) create mode 100644 apps/mcp-server/__tests__/create-mcp-server.test.ts create mode 100644 packages/business/__tests__/capabilities-service.test.ts create mode 100644 packages/flow-config/__tests__/authoring/spec-schema.test.ts diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index eae88dd569..681e7026ea 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -1,5 +1,8 @@ +import { FlowAuthoringException } from "@chatbotx.io/flow-config" import { beforeEach, describe, expect, test, vi } from "vitest" +const SPEC_STEP_PATH_PATTERN = /^steps\[/ + type RouteConfig = { method: string path: string @@ -75,8 +78,6 @@ vi.mock("@chatbotx.io/business/errors", () => ({ const getFlowAuthoringContext = vi.fn(async () => ({ templatesByName: new Map(), - inboxesByName: new Map(), - tagsByName: new Map(), customFieldsByName: new Map(), flowsByName: new Map(), })) @@ -316,6 +317,40 @@ describe("POST /v1/flows/{id}/publish", () => { expect(call.nodes[0].type).toBe("sendMessage") expect(call.edges).toEqual([]) }) + + test("throws FlowAuthoringException (not a raw 500) when the compiled spec fails channel refinement", async () => { + const input = { + id: "flow-1", + spec: { + formatVersion: 1, + name: "Spec flow", + channel: "instagram", + steps: [ + { + type: "send", + imageUrl: "https://example.com/a.png", + buttons: [{ text: "Yes" }], + }, + ], + }, + } + + let caught: unknown + try { + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input, + }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(FlowAuthoringException) + expect((caught as FlowAuthoringException).errors[0]?.path).toMatch( + SPEC_STEP_PATH_PATTERN, + ) + expect(flowVersionService.publish).not.toHaveBeenCalled() + }) }) describe("POST /v1/flows/validate", () => { @@ -340,6 +375,29 @@ describe("POST /v1/flows/validate", () => { expect(flowVersionService.publish).not.toHaveBeenCalled() expect(flowVersionService.updateDraftByFlowId).not.toHaveBeenCalled() }) + + test("throws FlowAuthoringException for an unknown template name", async () => { + let caught: unknown + try { + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + spec: { + formatVersion: 1, + name: "Spec flow", + steps: [{ type: "sendTemplate", templateName: "does_not_exist" }], + }, + }, + }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(FlowAuthoringException) + expect((caught as FlowAuthoringException).errors[0]?.code).toBe( + "unknownTemplate", + ) + }) }) describe("PUT /v1/flows/{id}/draft", () => { diff --git a/apps/builder/__tests__/orpc-error-mapping.test.ts b/apps/builder/__tests__/orpc-error-mapping.test.ts index 3ba4d43862..a0cf413ead 100644 --- a/apps/builder/__tests__/orpc-error-mapping.test.ts +++ b/apps/builder/__tests__/orpc-error-mapping.test.ts @@ -63,9 +63,11 @@ vi.mock("@/lib/workspace/authorize-workspace-access", () => ({ const { ChatbotXException } = await import("@chatbotx.io/business/errors") const { ModelNotfoundException } = await import("@chatbotx.io/database/errors") +const { FlowAuthoringException } = await import("@chatbotx.io/flow-config") const { SdkException } = await import("@chatbotx.io/sdk") const { ActionValidationError } = await import("next-safe-action") const { ORPCError } = await import("@orpc/server") +const { z } = await import("zod") const { mapKnownOrpcErrors } = await import("@/orpc") @@ -165,4 +167,37 @@ describe("mapKnownOrpcErrors", () => { expect(mockLoggerError).not.toHaveBeenCalled() expect(mockLoggerWarn).not.toHaveBeenCalled() }) + + test("maps FlowAuthoringException to a 422 invalidRequestData with its structured errors", () => { + const error = new FlowAuthoringException([ + { + path: "steps[0].templateName", + code: "unknownTemplate", + message: 'No WhatsApp template named "welcom_promo" in this workspace.', + }, + ]) + + expect(() => mapKnownOrpcErrors(error)).toThrow( + expect.objectContaining({ + code: "invalidRequestData", + status: 422, + data: error.errors, + }), + ) + expect(mockLoggerWarn).toHaveBeenCalledTimes(1) + }) + + test("maps a raw ZodError to a 422 invalidRequestData as a safety net", () => { + const result = z.object({ name: z.string() }).safeParse({}) + const error = result.error as InstanceType + + expect(() => mapKnownOrpcErrors(error)).toThrow( + expect.objectContaining({ + code: "invalidRequestData", + status: 422, + data: error.issues, + }), + ) + expect(mockLoggerWarn).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/builder/__tests__/public-spec-mcp.test.ts b/apps/builder/__tests__/public-spec-mcp.test.ts index 247a860cc2..1725e748f2 100644 --- a/apps/builder/__tests__/public-spec-mcp.test.ts +++ b/apps/builder/__tests__/public-spec-mcp.test.ts @@ -147,3 +147,10 @@ describe("default tool set", () => { ).toMatchSnapshot() }) }) + +describe("read-only-safe POST operations", () => { + test("contacts.search carries x-mcp.readOnlyHint: true so a read_only token still sees it", () => { + const op = operations.find((o) => o.operationId === "contacts.search") + expect(op?.["x-mcp"]?.readOnlyHint).toBe(true) + }) +}) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index a0786b3833..b1b2fde424 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -231,8 +231,8 @@ describe("public API spec — operation naming guard", () => { // workspace/inbox identity — that IS the endpoint's purpose. "channels.me", // `token.get` legitimately echoes the calling token's own workspace - // id, permission, and scopes — that IS the endpoint's purpose (P2.3 - // token introspection), same rationale as `channels.me`. + // id, permission, and scopes — that IS the endpoint's purpose (token + // introspection), same rationale as `channels.me`. "token.get", // Pre-existing leaks, confirmed present on `main` before the analytics diff --git a/apps/builder/src/features/capabilities/api/public.ts b/apps/builder/src/features/capabilities/api/public.ts index e6ccc6d52c..79b912aad9 100644 --- a/apps/builder/src/features/capabilities/api/public.ts +++ b/apps/builder/src/features/capabilities/api/public.ts @@ -13,8 +13,8 @@ import { workspaceTokenAuthAPIForScope } from "@/orpc" // exactly like `GET /v1/contacts/filter-fields` (see // `features/contact-filter/api/public.ts`): the most common scope, and this // only ever returns metadata (ids/names), never contact data. `alwaysVisible` -// (P2.3) exempts it from scope-based `tools/list` filtering so a token -// missing `contacts` still sees this tool and its 403, instead of the tool +// exempts it from scope-based `tools/list` filtering so a token missing +// `contacts` still sees this tool and its 403, instead of the tool // disappearing without a trace. const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") @@ -69,6 +69,12 @@ const includeQueryParam = z.preprocess((value) => { }, z.array(z.enum(CAPABILITIES_INCLUDES)).optional()) const flowSpecJsonSchemaConverter = new ZodToJsonSchemaConverter() +// `flowSpecSchema` is static — converted once at module load rather than on +// every `schemas.flowSpec` request. +const [, flowSpecJsonSchema] = flowSpecJsonSchemaConverter.convert( + flowSpecSchema, + { strategy: "input" }, +) export const capabilitiesPublicRouter = { get: workspaceTokenAuthAPI @@ -108,13 +114,5 @@ export const schemasPublicRouter = { .input(z.object({})) .output(z.record(z.string(), z.unknown())) .errors(possibleErrorsOnListingResource) - .handler(() => { - const [, jsonSchema] = flowSpecJsonSchemaConverter.convert( - flowSpecSchema, - { - strategy: "input", - }, - ) - return jsonSchema as Record - }), + .handler(() => flowSpecJsonSchema as Record), } diff --git a/apps/builder/src/features/contacts/api/public/crud.ts b/apps/builder/src/features/contacts/api/public/crud.ts index 4fd8e40f07..5273e4c7a5 100644 --- a/apps/builder/src/features/contacts/api/public/crud.ts +++ b/apps/builder/src/features/contacts/api/public/crud.ts @@ -65,7 +65,11 @@ export const contactsCrudPublicRouter = { description: "Same as `GET /v1/contacts` but accepts the filter as a JSON request body instead of query parameters — use this when `contactFilter` is large or deeply nested. Supports the same `include`/`withCount` options.", tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), + // A POST that reads, not writes — `readOnlyHint: true` keeps it + // visible to a `read_only` token (`isVisibleForScope` in + // `apps/mcp-server/src/openapi-loader.ts`), which would otherwise + // hide every non-GET tool. + spec: mcpSpec({ visibility: "default", readOnlyHint: true }), }) .input(listContactsPublicRequest) .output(listContactsResponse) diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index a1d5eb64ad..4d6dd8aa8c 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -18,7 +18,10 @@ import { } from "@/lib/orpc/orpc-error-helper" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" -import { compileSpecToGraph } from "../lib/compile-spec-to-graph" +import { + compileAndValidateSpec, + compileSpecToGraph, +} from "../lib/compile-spec-to-graph" import { createFlowSchema, flowSpecRequest, @@ -171,9 +174,7 @@ export const flowsPublicRouter = { const workspaceId = context.workspace.id const { nodes, edges } = "spec" in input - ? publishFlowSchema.parse( - await compileSpecToGraph(input.spec, workspaceId), - ) + ? await compileAndValidateSpec(input.spec, workspaceId) : input await flowVersionService.publish({ workspaceId, @@ -197,9 +198,7 @@ export const flowsPublicRouter = { .output(publishFlowSchema) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => - publishFlowSchema.parse( - await compileSpecToGraph(input.spec, context.workspace.id), - ), + compileAndValidateSpec(input.spec, context.workspace.id), ), updateDraft: workspaceTokenAuthAPI diff --git a/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts index 44bdf4c3b2..92271ec371 100644 --- a/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts +++ b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts @@ -2,9 +2,13 @@ import { getFlowAuthoringContext } from "@chatbotx.io/business/capabilities" import { compileFlowSpec, type EdgeSchema, + FlowAuthoringException, type FlowSpec, type FlowVersionSchema, + formatZodPathSegment, + zodErrorToFlowAuthoringErrors, } from "@chatbotx.io/flow-config" +import { publishFlowSchema } from "../schema/action" /** * Resolves a `{ spec }` flow-authoring request into the raw `{ nodes, edges }` @@ -20,3 +24,57 @@ export async function compileSpecToGraph( const { nodes, edges } = compileFlowSpec(spec, ctx) return { nodes, edges } } + +/** + * Translates a `publishFlowSchema` issue path — `["nodes", nodeIndex, + * ...rest]`, since `refineStepsByChannel` re-anchors onto the node it found + * the problem in (`channel-step-refinement.ts`) — back to the spec-relative + * path (e.g. `steps[2].buttons[0].then[0]`) the caller actually wrote, via + * the node id `compileFlowSpec` recorded it under. `undefined` (never the + * bare zod path) when the issue doesn't have that shape, so a genuinely + * unexpected issue still surfaces instead of silently mislabeling it. + */ +function mapPublishIssuePath( + issuePath: PropertyKey[], + nodes: readonly FlowVersionSchema[], + specPathByNodeId: ReadonlyMap, +): string | undefined { + const [field, nodeIndex, ...rest] = issuePath + if (field !== "nodes" || typeof nodeIndex !== "number") { + return + } + const nodeId = nodes[nodeIndex]?.id + const specPath = nodeId ? specPathByNodeId.get(nodeId) : undefined + if (!specPath) { + return + } + return rest.reduce(formatZodPathSegment, specPath) +} + +/** + * `flows.publish`/`flows.validate`'s `{ spec }` path: compiles the spec, then + * validates the result exactly like a raw `{ nodes, edges }` publish would + * (`publishFlowSchema`, which runs channel rules `compileFlowSpec` itself + * never runs). A validation failure here is remapped onto the spec-relative + * path the agent wrote and thrown as `FlowAuthoringException` — the single + * error contract every flow-authoring failure uses — instead of a raw + * `ZodError` pointing at compiled-node internals. + */ +export async function compileAndValidateSpec( + spec: FlowSpec, + workspaceId: string, +): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> { + const ctx = await getFlowAuthoringContext(workspaceId) + const { nodes, edges, specPathByNodeId } = compileFlowSpec(spec, ctx) + + const result = publishFlowSchema.safeParse({ nodes, edges }) + if (!result.success) { + throw new FlowAuthoringException( + zodErrorToFlowAuthoringErrors(result.error, "invalidStep", (issuePath) => + mapPublishIssuePath(issuePath, nodes, specPathByNodeId), + ), + ) + } + + return result.data +} diff --git a/apps/builder/src/features/flows/schema/action.ts b/apps/builder/src/features/flows/schema/action.ts index a7bbd8e0f4..f9ecc387a5 100644 --- a/apps/builder/src/features/flows/schema/action.ts +++ b/apps/builder/src/features/flows/schema/action.ts @@ -1,6 +1,5 @@ import { edgeSchema, - type FlowSpec, flowSpecSchema, flowVersionSchema, refineStepsByChannel, @@ -21,44 +20,36 @@ export const updateFlowSchema = z.object({ }) export type UpdateFlowSchema = z.infer -const updateDraftFlowVersionNodesSchema = z.object({ +export const updateDraftFlowVersionSchema = z.object({ nodes: z.array(z.any()), edges: z.array(edgeSchema), }) -export const updateDraftFlowVersionSchema = updateDraftFlowVersionNodesSchema export type UpdateDraftFlowVersionSchema = z.infer< typeof updateDraftFlowVersionSchema > /** `{ spec }` input, accepted by `flows.publish`/`flows.updateDraft` alongside the raw `{ nodes, edges }` shape, and the sole input of `flows.validate`. */ export const flowSpecRequest = z.object({ - spec: flowSpecSchema satisfies z.ZodType, + spec: flowSpecSchema, }) -export type FlowSpecRequest = z.infer /** Draft update accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored. */ export const updateDraftFlowRequest = z.union([ - updateDraftFlowVersionNodesSchema, + updateDraftFlowVersionSchema, flowSpecRequest, ]) -export type UpdateDraftFlowRequest = z.infer // Channel rules are declared per step (see // `@chatbotx.io/flow-config/channel-rules`), so this stays one generic hook // instead of accumulating a refinement per channel/step pair. -const publishFlowNodesSchema = z.object({ +export const publishFlowSchema = z.object({ nodes: z.array(flowVersionSchema).superRefine(refineStepsByChannel), edges: z.array(edgeSchema), }) -export const publishFlowSchema = publishFlowNodesSchema export type PublishFlowSchema = z.infer /** Publish accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored — compiled server-side into the same graph shape before publishing. */ -export const publishFlowRequest = z.union([ - publishFlowNodesSchema, - flowSpecRequest, -]) -export type PublishFlowRequest = z.infer +export const publishFlowRequest = z.union([publishFlowSchema, flowSpecRequest]) // Reuse the package-level node union so client-side publish validation can // never drift from the server-side `publishFlowSchema` when node types are added. diff --git a/apps/builder/src/features/token/api/public.ts b/apps/builder/src/features/token/api/public.ts index bb8e345fcf..fb880b4c96 100644 --- a/apps/builder/src/features/token/api/public.ts +++ b/apps/builder/src/features/token/api/public.ts @@ -11,9 +11,9 @@ import { workspaceTokenAuthAPIForScope } from "@/orpc" // Same scope and rationale as `capabilities.get` // (`features/capabilities/api/public.ts`): endpoint discovery, not a // resource read, so it reuses the most common scope rather than adding a -// 13th one just for this. `alwaysVisible` (P2.3) exempts it from -// scope-based `tools/list` filtering — a token missing `contacts` still -// sees this tool, whose whole job is telling it exactly that. +// 13th one just for this. `alwaysVisible` exempts it from scope-based +// `tools/list` filtering — a token missing `contacts` still sees this +// tool, whose whole job is telling it exactly that. const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") const tokenPublicResponse = z.object({ diff --git a/apps/builder/src/lib/orpc/mcp-annotations.ts b/apps/builder/src/lib/orpc/mcp-annotations.ts index e7cb3ad643..d31c9fa25c 100644 --- a/apps/builder/src/lib/orpc/mcp-annotations.ts +++ b/apps/builder/src/lib/orpc/mcp-annotations.ts @@ -4,16 +4,16 @@ import type { OpenAPI } from "@orpc/openapi" * `"default"` = shipped in `tools/list` on every MCP connection. `"hidden"` * (or the field absent — see `mcpSpec` below) = reachable only through the * `search_tools` / `call_tool` meta-tools. We cannot ask every one of the - * ~346 public operations to opt out individually, so the polarity is + * ~350 public operations to opt out individually, so the polarity is * inverted: opt IN to `"default"` on the ~40 that should always be visible. */ -export type McpVisibility = "default" | "hidden" +type McpVisibility = "default" | "hidden" export type McpRouteMeta = { /** Absent ⇒ treated as `"hidden"` by the mcp-server loader. */ visibility?: McpVisibility /** - * Exempts this operation from scope-based `tools/list` filtering (P2.3) — + * Exempts this operation from scope-based `tools/list` filtering — * reserved for the small set of discovery endpoints (`capabilities.get`, * `token.get`) that a token must be able to *see* even when it lacks the * scope those endpoints themselves require, so the 403 body is visible to diff --git a/apps/builder/src/orpc.ts b/apps/builder/src/orpc.ts index 9ec92a91ce..dcfa841b8d 100644 --- a/apps/builder/src/orpc.ts +++ b/apps/builder/src/orpc.ts @@ -9,6 +9,7 @@ import { SdkException } from "@chatbotx.io/sdk" import { oo } from "@orpc/openapi" import { ORPCError, onError, ValidationError } from "@orpc/server" import { ActionValidationError } from "next-safe-action" +import { z } from "zod" import { logger } from "./lib/log" import type { OperationObjectWithMcp } from "./lib/orpc/mcp-annotations" import { commonApiErrors } from "./lib/orpc/orpc-error-helper" @@ -109,6 +110,17 @@ function toKnownOrpcError( }) } + // Safety net for a raw `ZodError` a handler lets escape directly (e.g. + // `someSchema.parse(...)` outside oRPC's own input validation) — same 422 + // shape as the mapped cases above, instead of falling through to a 500. + if (error instanceof z.ZodError) { + return new ORPCError("invalidRequestData", { + message: error.message, + status: 422, + data: error.issues, + }) + } + return } @@ -169,7 +181,7 @@ const requireTokenScope = (scope: WorkspaceApiTokenScope) => * generator's `applyCustomOpenAPIOperation` (which walks * `contract["~orpc"].middlewares`) stamps `x-mcp.scope` on every operation * that chains through this middleware — one edit here instead of touching - * every one of the ~450 scoped route files. By the time this extender runs, + * every one of the ~70 scoped route files. By the time this extender runs, * `current` already reflects the route's own `.route({ spec: mcpSpec(...) })` * (applied earlier, during operation generation), so spreading * `current["x-mcp"]` before writing `scope` keeps a route's declared diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index 687f4312c5..bc64d9b9a7 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -103,6 +103,7 @@ export const publicRouter = { questionnaires: questionnairesPublicRouter, reflinks: reflinksPublicRouter, savedReplies: savedRepliesPublicRouter, + schemas: schemasPublicRouter, sequences: sequencesPublicRouter, smtpIntegrations: smtpIntegrationsPublicRouter, spreadsheets: spreadsheetsPublicRouter, @@ -115,5 +116,4 @@ export const publicRouter = { webhooks: webhooksPublicRouter, workspaceMembers: workspaceMembersPublicRouter, zaloChannels: zaloChannelsPublicRouter, - schemas: schemasPublicRouter, } diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index aae606316c..6220909646 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -8,7 +8,7 @@ On startup the server fetches `{CHATBOTX_API_URL}/public-spec.json` and register ### Default tools vs. the full API -ChatbotX's public API has ~300 operations. Listing all of them as MCP tools overwhelms an agent's context and its ability to pick the right one, so `tools/list` returns only a curated default set (see below) plus two meta-tools: +ChatbotX's public API has ~350 operations. Listing all of them as MCP tools overwhelms an agent's context and its ability to pick the right one, so `tools/list` returns only a curated default set (see below) plus two meta-tools: | Tool | Description | |---|---| @@ -22,7 +22,7 @@ Use `search_tools` when the task needs something outside the default set (e.g. d `tools/list` is further narrowed to what the calling token can actually use, resolved once per token via `GET /v1/token` (`introspectToken`, cached per token value for `CHATBOTX_SPEC_TTL_MS`): - A token missing a scope never sees that scope's tools (they still exist for `search_tools`/`call_tool`, which always hit the real API and get a real 403 if unauthorized). -- A `read_only` token only sees `GET` tools, plus a small allowlist of POST endpoints that are reads in disguise (currently `contacts_search`, a filter-body search). +- A `read_only` token only sees tools whose `readOnlyHint` annotation is true — every `GET` by default, plus any POST explicitly marked `x-mcp.readOnlyHint: true` for endpoints that are reads in disguise (currently `contacts_search`, a filter-body search). - `capabilities_get` and `token_get` are always visible regardless of scope — an agent needs them to discover what it *can* do and what its token allows before anything else works. - If token introspection itself fails (network blip, unreachable API), filtering fails open — `tools/list` falls back to the full default set. The actual API call still enforces the token's real permissions either way. diff --git a/apps/mcp-server/SKILL.md b/apps/mcp-server/SKILL.md index 17efefe5ff..5e12480fbe 100644 --- a/apps/mcp-server/SKILL.md +++ b/apps/mcp-server/SKILL.md @@ -224,7 +224,7 @@ chatbotx error-logs list # [--page --perPage --sort ## MCP Tools (for AI agents) -Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 44 tools** — not the full ~300-operation API — plus two meta-tools that reach everything else: +Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 44 tools** — not the full ~350-operation API — plus two meta-tools that reach everything else: | Tool | Description | |---|---| diff --git a/apps/mcp-server/__tests__/create-mcp-server.test.ts b/apps/mcp-server/__tests__/create-mcp-server.test.ts new file mode 100644 index 0000000000..d96abd88fc --- /dev/null +++ b/apps/mcp-server/__tests__/create-mcp-server.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +// Same convention as openapi-loader.test.ts / meta-tools.test.ts: +// `getCachedTools()`/`introspectToken()`'s caches are module-level state, so +// each test needs a fresh module instance (`vi.resetModules()`) and its own +// fetch mock. +type FetchResponse = { + ok: boolean + headers: { get: (name: string) => string | null } + json: () => Promise +} + +const specResponse = (paths: Record): FetchResponse => ({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths, + }), +}) + +const tokenResponse = (body: unknown): FetchResponse => ({ + ok: true, + headers: { get: () => null }, + json: async () => body, +}) + +const jsonExecuteResponse = (body: unknown): FetchResponse => ({ + ok: true, + headers: { + get: (name: string) => + name === "content-type" ? "application/json" : null, + }, + json: async () => body, +}) + +type RequestHandler = (request: unknown, extra: unknown) => Promise +type ListToolsResult = { tools: Array<{ name: string }> } +type CallToolResult = { + isError?: boolean + content: Array<{ type: string; text: string }> +} + +/** + * `createMcpServer` bypasses the SDK's high-level tool API and registers + * handlers directly on the underlying low-level `Server` via + * `mcpServer.server.setRequestHandler`. There is no public API to invoke a + * registered handler without a live transport, so this reaches into the + * SDK's `Protocol._requestHandlers` map (populated by `setRequestHandler`) + * — the same map `Server`'s own request dispatch reads from. + */ +function getRequestHandler( + server: { server: object }, + method: string, +): RequestHandler { + const requestHandlers = Reflect.get(server.server, "_requestHandlers") as Map< + string, + RequestHandler + > + const handler = requestHandlers.get(method) + if (!handler) { + throw new Error(`No request handler registered for ${method}`) + } + return handler +} + +describe("createMcpServer", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("tools/list returns META_TOOLS followed by visible tools, and passes introspection through", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + specResponse({ + "/v1/tags": { + get: { + operationId: "tags.list", + summary: "List tags", + "x-mcp": { visibility: "default", scope: "contacts" }, + }, + }, + "/v1/flows": { + get: { + operationId: "flows.list", + summary: "List flows", + "x-mcp": { visibility: "default", scope: "automation" }, + }, + }, + "/v1/minigames": { + get: { operationId: "minigames.list", summary: "List minigames" }, + }, + }), + ) + .mockResolvedValueOnce( + tokenResponse({ + workspaceId: "ws-1", + permission: "full", + scopes: ["contacts"], + }), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { createMcpServer } = await import("../src/server/create-mcp-server") + const server = createMcpServer({ getApiKey: () => "api-key" }) + + const listHandler = getRequestHandler(server, "tools/list") + const result = (await listHandler( + { method: "tools/list" }, + {}, + )) as ListToolsResult + + // `flows_list` (scope: automation) is excluded — the introspected token + // only holds `contacts` — proving the introspection result actually + // reached `getVisibleTools`, not just that some filtering ran. + expect(result.tools.map((tool: { name: string }) => tool.name)).toEqual([ + "search_tools", + "call_tool", + "tags_list", + ]) + }) + + test("a hidden tool (excluded from tools/list) is reachable via call_tool", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + specResponse({ + "/v1/minigames": { + get: { operationId: "minigames.list", summary: "List minigames" }, + }, + }), + ) + .mockResolvedValueOnce( + tokenResponse({ + workspaceId: "ws-1", + permission: "full", + scopes: null, + }), + ) + .mockResolvedValueOnce(jsonExecuteResponse({ data: [] })) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { createMcpServer } = await import("../src/server/create-mcp-server") + const server = createMcpServer({ getApiKey: () => "api-key" }) + + // Warm the spec cache and confirm the tool is genuinely absent from + // tools/list before reaching it through call_tool. + const listHandler = getRequestHandler(server, "tools/list") + const listResult = (await listHandler( + { method: "tools/list" }, + {}, + )) as ListToolsResult + expect( + listResult.tools.map((tool: { name: string }) => tool.name), + ).not.toContain("minigames_list") + + const callHandler = getRequestHandler(server, "tools/call") + const result = (await callHandler( + { + method: "tools/call", + params: { name: "minigames_list", arguments: {} }, + }, + {}, + )) as CallToolResult + + expect(result.isError).toBeUndefined() + }) + + test("missing API key blocks a regular tool and call_tool, but not search_tools", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + specResponse({ + "/v1/tags": { + get: { + operationId: "tags.list", + summary: "List tags", + "x-mcp": { visibility: "default" }, + }, + }, + }), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + + const { createMcpServer } = await import("../src/server/create-mcp-server") + const server = createMcpServer({ getApiKey: () => "" }) + const callHandler = getRequestHandler(server, "tools/call") + + const regularToolResult = (await callHandler( + { method: "tools/call", params: { name: "tags_list", arguments: {} } }, + {}, + )) as CallToolResult + expect(regularToolResult.isError).toBe(true) + expect(regularToolResult.content[0]?.text).toContain( + "No workspace token configured", + ) + + const callToolResult = (await callHandler( + { + method: "tools/call", + params: { name: "call_tool", arguments: { name: "tags_list" } }, + }, + {}, + )) as CallToolResult + expect(callToolResult.isError).toBe(true) + expect(callToolResult.content[0]?.text).toContain( + "No workspace token configured", + ) + + const searchToolsResult = (await callHandler( + { + method: "tools/call", + params: { name: "search_tools", arguments: { query: "tags" } }, + }, + {}, + )) as CallToolResult + expect(searchToolsResult.isError).toBeUndefined() + }) + + test("'toString' is not treated as a meta-tool name", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(specResponse({})) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + + const { createMcpServer } = await import("../src/server/create-mcp-server") + const server = createMcpServer({ getApiKey: () => "api-key" }) + const callHandler = getRequestHandler(server, "tools/call") + + const result = (await callHandler( + { method: "tools/call", params: { name: "toString", arguments: {} } }, + {}, + )) as CallToolResult + + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toBe("Unknown tool: toString") + }) +}) diff --git a/apps/mcp-server/__tests__/meta-tools.test.ts b/apps/mcp-server/__tests__/meta-tools.test.ts index af05dfdbcc..fa2802df17 100644 --- a/apps/mcp-server/__tests__/meta-tools.test.ts +++ b/apps/mcp-server/__tests__/meta-tools.test.ts @@ -40,10 +40,7 @@ describe("META_TOOLS", () => { "search_tools", "call_tool", ]) - expect(Object.keys(META_TOOL_NAMES).sort()).toEqual([ - "call_tool", - "search_tools", - ]) + expect([...META_TOOL_NAMES].sort()).toEqual(["call_tool", "search_tools"]) }) }) diff --git a/apps/mcp-server/__tests__/openapi-loader.test.ts b/apps/mcp-server/__tests__/openapi-loader.test.ts index 53626c1c56..744b5e9bd7 100644 --- a/apps/mcp-server/__tests__/openapi-loader.test.ts +++ b/apps/mcp-server/__tests__/openapi-loader.test.ts @@ -430,7 +430,11 @@ describe("getVisibleTools", () => { post: { operationId: "contacts.search", summary: "Search contacts", - "x-mcp": { visibility: "default", scope: "contacts" }, + "x-mcp": { + visibility: "default", + scope: "contacts", + readOnlyHint: true, + }, }, }, "/v1/tags/{id}": { @@ -487,7 +491,7 @@ describe("getVisibleTools", () => { await loadOpenApiSpec() expect( - getVisibleTools({ permission: "full", scopes: null }) + getVisibleTools({ permission: "full", scopes: null, workspaceId: "ws-1" }) .map((t) => t.name) .sort(), ).toEqual([ @@ -509,13 +513,17 @@ describe("getVisibleTools", () => { await loadOpenApiSpec() expect( - getVisibleTools({ permission: "full", scopes: ["automation"] }) + getVisibleTools({ + permission: "full", + scopes: ["automation"], + workspaceId: "ws-1", + }) .map((t) => t.name) .sort(), ).toEqual(["capabilities_get", "flows_list"]) }) - test("a read_only token only sees GET tools plus the read-disguised-as-POST allowlist", async () => { + test("a read_only token only sees GET tools plus readOnlyHint POST tools", async () => { globalThis.fetch = vi .fn() .mockResolvedValue(specWithScopedTools()) as unknown as typeof fetch @@ -528,6 +536,7 @@ describe("getVisibleTools", () => { getVisibleTools({ permission: "read_only", scopes: ["contacts", "automation"], + workspaceId: "ws-1", }) .map((t) => t.name) .sort(), diff --git a/apps/mcp-server/__tests__/token-introspection.test.ts b/apps/mcp-server/__tests__/token-introspection.test.ts index 1ee194cddf..85866a0d88 100644 --- a/apps/mcp-server/__tests__/token-introspection.test.ts +++ b/apps/mcp-server/__tests__/token-introspection.test.ts @@ -47,6 +47,16 @@ describe("introspectToken", () => { await expect(introspectToken("token-bad")).resolves.toBeNull() }) + test("returns null when the response body doesn't match the expected shape", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ permission: "not-a-real-permission" }), + }) as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await expect(introspectToken("token-malformed")).resolves.toBeNull() + }) + test("returns null when the fetch itself rejects", async () => { globalThis.fetch = vi .fn() @@ -112,4 +122,27 @@ describe("introspectToken", () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + + test("evicts expired cache entries once a subsequent lookup inserts a new one", async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + workspaceId: "workspace-1", + permission: "full", + scopes: null, + }), + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-old") + // Default CHATBOTX_SPEC_TTL_MS is 300_000ms. + vi.advanceTimersByTime(300_001) + + const deleteSpy = vi.spyOn(Map.prototype, "delete") + await introspectToken("token-new") + + expect(deleteSpy).toHaveBeenCalledWith("token-old") + }) }) diff --git a/apps/mcp-server/src/openapi-loader.ts b/apps/mcp-server/src/openapi-loader.ts index 90f60e2e04..6017368e79 100644 --- a/apps/mcp-server/src/openapi-loader.ts +++ b/apps/mcp-server/src/openapi-loader.ts @@ -1,4 +1,5 @@ import { env } from "./env" +import type { TokenIntrospection } from "./token-introspection" interface OpenAPISpec { paths?: Record> @@ -84,7 +85,7 @@ interface OpenAPISchemaObject { type?: string } -export interface DynamicToolAnnotations { +interface DynamicToolAnnotations { destructiveHint: boolean idempotentHint: boolean readOnlyHint: boolean @@ -358,21 +359,9 @@ export function getCachedTools(): DynamicTool[] { return cachedTools ?? [] } -export type TokenScopeIntrospection = { - permission: "read_only" | "full" - scopes: string[] | null -} - -// Plan carve-out: a read-only token still needs the handful of POST -// endpoints that are reads in disguise (a filter body instead of query -// params) — `contacts_search` mirrors `contacts_list` exactly. -const READ_ONLY_ALLOWED_POST_TOOLS: Record = { - contacts_search: true, -} - function isVisibleForScope( tool: DynamicTool, - introspection: TokenScopeIntrospection | null, + introspection: TokenIntrospection | null, ): boolean { // Fail OPEN: introspection unavailable (network blip, unexpected // response) must not hide every tool — enforcement of scope/permission @@ -382,10 +371,12 @@ function isVisibleForScope( return true } + // `readOnlyHint` (method-inferred for GET, or explicit `x-mcp.readOnlyHint` + // for a POST that's a read in disguise, e.g. `contacts_search`) is the + // single source of truth here — no separate allowlist to keep in sync. if ( introspection.permission === "read_only" && - tool.method !== "GET" && - !READ_ONLY_ALLOWED_POST_TOOLS[tool.name] + !tool.annotations.readOnlyHint ) { return false } @@ -407,7 +398,7 @@ function isVisibleForScope( * `getCachedTools()`, which is never filtered. */ export function getVisibleTools( - introspection?: TokenScopeIntrospection | null, + introspection?: TokenIntrospection | null, ): DynamicTool[] { return getCachedTools() .filter((tool) => tool.visibility === "default") diff --git a/apps/mcp-server/src/server/create-mcp-server.ts b/apps/mcp-server/src/server/create-mcp-server.ts index 9c1000e5da..29b2f3533f 100644 --- a/apps/mcp-server/src/server/create-mcp-server.ts +++ b/apps/mcp-server/src/server/create-mcp-server.ts @@ -74,6 +74,12 @@ export const createMcpServer = ( const { name, arguments: args } = request.params const toolArgs = (args ?? {}) as Record + // `search_tools` is read-only discovery over the cached tool list — it + // needs no workspace token, unlike `call_tool` and every regular tool. + if (name === "search_tools") { + return handleSearchTools(toolArgs) + } + const apiKey = getApiKey() if (!apiKey) { return { @@ -82,10 +88,8 @@ export const createMcpServer = ( } } - if (name in META_TOOL_NAMES) { - return name === "search_tools" - ? handleSearchTools(toolArgs) - : await handleCallTool(toolArgs, apiKey) + if (META_TOOL_NAMES.has(name)) { + return await handleCallTool(toolArgs, apiKey) } const tool = findToolByName(name) diff --git a/apps/mcp-server/src/server/execute-tool.ts b/apps/mcp-server/src/server/execute-tool.ts index 66f3c88a52..61069b6532 100644 --- a/apps/mcp-server/src/server/execute-tool.ts +++ b/apps/mcp-server/src/server/execute-tool.ts @@ -1,10 +1,6 @@ import type { DynamicTool } from "../openapi-loader" -const NO_BODY_METHODS: Record = { - GET: true, - HEAD: true, - DELETE: true, -} +const NO_BODY_METHODS: ReadonlySet = new Set(["GET", "HEAD", "DELETE"]) function buildQueryString(params: Record): string { const qs = new URLSearchParams(params).toString() @@ -62,7 +58,7 @@ export async function executeTool( const url = `${tool.baseUrl}${path}${buildQueryString(queryArgs)}` const sendBody = - !NO_BODY_METHODS[tool.method] && tool.bodyParamNames.length > 0 + !NO_BODY_METHODS.has(tool.method) && tool.bodyParamNames.length > 0 try { const response = await fetch(url, { diff --git a/apps/mcp-server/src/server/meta-tools.ts b/apps/mcp-server/src/server/meta-tools.ts index b87588759c..cae3ab90c8 100644 --- a/apps/mcp-server/src/server/meta-tools.ts +++ b/apps/mcp-server/src/server/meta-tools.ts @@ -47,10 +47,9 @@ export const META_TOOLS = [ }, ] as const -export const META_TOOL_NAMES: Record = { - search_tools: true, - call_tool: true, -} +export const META_TOOL_NAMES: ReadonlySet = new Set( + META_TOOLS.map((tool) => tool.name), +) const DEFAULT_SEARCH_LIMIT = 10 const MAX_SEARCH_LIMIT = 25 @@ -66,20 +65,41 @@ function tokenize(text: string): string[] { return text.toLowerCase().match(/[a-z0-9]+/g) ?? [] } +type ToolTokens = { name: Set; description: Set } + +// Keyed by the `DynamicTool` object itself (not its name): `openapi-loader` +// hands out a fresh array of tool objects on every spec refresh, so a stale +// entry is naturally unreachable and garbage-collected — no manual +// invalidation needed when the spec changes. +const toolTokensCache = new WeakMap() + +function getToolTokens(tool: DynamicTool): ToolTokens { + const cached = toolTokensCache.get(tool) + if (cached) { + return cached + } + const tokens: ToolTokens = { + name: new Set(tokenize(tool.name)), + description: new Set(tokenize(tool.description)), + } + toolTokensCache.set(tool, tokens) + return tokens +} + function scoreTool( tool: DynamicTool, queryTokens: string[], queryPhrase: string, ): number { - const nameTokens = tokenize(tool.name) - const descriptionTokens = tokenize(tool.description) + const { name: nameTokens, description: descriptionTokens } = + getToolTokens(tool) let score = 0 for (const token of queryTokens) { - if (nameTokens.includes(token)) { + if (nameTokens.has(token)) { score += NAME_TOKEN_WEIGHT } - if (descriptionTokens.includes(token)) { + if (descriptionTokens.has(token)) { score += DESCRIPTION_TOKEN_WEIGHT } } diff --git a/apps/mcp-server/src/server/sse-server.ts b/apps/mcp-server/src/server/sse-server.ts index 6e181c9601..4dee88276d 100644 --- a/apps/mcp-server/src/server/sse-server.ts +++ b/apps/mcp-server/src/server/sse-server.ts @@ -260,6 +260,9 @@ const handleMessagesRequest = async ( } const apiKeyState = makeApiKeyState(req) + const server = createMcpServer({ + getApiKey: getApiKeyFromState(apiKeyState), + }) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (initializedSessionId) => { @@ -270,9 +273,6 @@ const handleMessagesRequest = async ( }) }, }) - const server = createMcpServer({ - getApiKey: getApiKeyFromState(apiKeyState), - }) transport.onclose = () => { const activeSessionId = transport.sessionId diff --git a/apps/mcp-server/src/token-introspection.ts b/apps/mcp-server/src/token-introspection.ts index 0ac49724b8..a79b24ac47 100644 --- a/apps/mcp-server/src/token-introspection.ts +++ b/apps/mcp-server/src/token-introspection.ts @@ -1,10 +1,13 @@ +import { z } from "zod" import { env } from "./env" -export type TokenIntrospection = { - workspaceId: string - permission: "read_only" | "full" - scopes: string[] | null -} +const tokenIntrospectionSchema = z.object({ + workspaceId: z.string(), + permission: z.enum(["read_only", "full"]), + scopes: z.array(z.string()).nullable(), +}) + +export type TokenIntrospection = z.infer /** * Cached by the raw token *value*, never by session — a session's token can @@ -17,9 +20,19 @@ const introspectionCache = new Map< { data: TokenIntrospection; fetchedAtMs: number } >() +/** Sweeps entries whose TTL has already elapsed so the cache can't grow unbounded across distinct tokens. */ +function evictExpiredEntries(): void { + const cutoffMs = Date.now() - env.CHATBOTX_SPEC_TTL_MS + for (const [apiKey, entry] of introspectionCache) { + if (entry.fetchedAtMs < cutoffMs) { + introspectionCache.delete(apiKey) + } + } +} + /** * Resolves a workspace token's scopes/permission via `GET /v1/token`, for - * scope-based `tools/list` filtering (P2.3). Returns `null` on any failure + * scope-based `tools/list` filtering. Returns `null` on any failure * (network error, non-2xx, malformed body) — callers must fail OPEN (skip * scope filtering, not hide every tool) on `null`: enforcement of scope * still happens server-side on the actual call; this is a `tools/list` @@ -43,9 +56,19 @@ export async function introspectToken( if (!response.ok) { return null } - const data = (await response.json()) as TokenIntrospection - introspectionCache.set(apiKey, { data, fetchedAtMs: Date.now() }) - return data + const parsed = tokenIntrospectionSchema.safeParse(await response.json()) + if (!parsed.success) { + console.error( + `Token introspection returned a malformed body, tools/list will not be scope-filtered: ${parsed.error.message}`, + ) + return null + } + evictExpiredEntries() + introspectionCache.set(apiKey, { + data: parsed.data, + fetchedAtMs: Date.now(), + }) + return parsed.data } catch (error) { console.error( `Token introspection failed, tools/list will not be scope-filtered: ${ diff --git a/packages/business/__tests__/capabilities-service.test.ts b/packages/business/__tests__/capabilities-service.test.ts new file mode 100644 index 0000000000..ba0462fe8a --- /dev/null +++ b/packages/business/__tests__/capabilities-service.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { aiAgentService } = await import("../src/ai-agent/service") +const { botFieldService } = await import("../src/bot-field/service") +const { customFieldService } = await import("../src/custom-field/service") +const { flowService } = await import("../src/flow/service") +const { inboxService } = await import("../src/inbox/service") +const { sequenceService } = await import("../src/sequence/service") +const { tagService } = await import("../src/tag/service") +const { whatsappMessageTemplateService } = await import( + "../src/whatsapp-message-template/service" +) +const { getCapabilities, getFlowAuthoringContext } = await import( + "../src/capabilities/service" +) + +const emptyListResult = { data: [], pageCount: 0 } + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(inboxService, "list").mockResolvedValue(emptyListResult as never) + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue( + [] as never, + ) + vi.spyOn(customFieldService, "list").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(botFieldService, "list").mockResolvedValue(emptyListResult as never) + vi.spyOn(tagService, "listActive").mockResolvedValue([] as never) + vi.spyOn(aiAgentService, "listAIAgents").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(sequenceService, "list").mockResolvedValue(emptyListResult as never) + vi.spyOn(flowService, "list").mockResolvedValue(emptyListResult as never) +}) + +describe("getCapabilities", () => { + test("omitting `include` fetches exactly the default set — flow-authoring essentials plus reference lists, not aiAgents", async () => { + const result = await getCapabilities({ workspaceId: "ws-1" }) + + expect(inboxService.list).toHaveBeenCalledTimes(1) + expect(whatsappMessageTemplateService.list).toHaveBeenCalledTimes(1) + expect(customFieldService.list).toHaveBeenCalledTimes(1) + expect(botFieldService.list).toHaveBeenCalledTimes(1) + expect(tagService.listActive).toHaveBeenCalledTimes(1) + expect(sequenceService.list).toHaveBeenCalledTimes(1) + expect(flowService.list).toHaveBeenCalledTimes(1) + expect(aiAgentService.listAIAgents).not.toHaveBeenCalled() + expect(result.aiAgents).toBeUndefined() + expect(result.flowSpec).toBeDefined() + }) + + test("`include` dispatches only the requested loaders", async () => { + const result = await getCapabilities({ + workspaceId: "ws-1", + include: ["aiAgents"], + }) + + expect(aiAgentService.listAIAgents).toHaveBeenCalledTimes(1) + expect(inboxService.list).not.toHaveBeenCalled() + expect(whatsappMessageTemplateService.list).not.toHaveBeenCalled() + expect(customFieldService.list).not.toHaveBeenCalled() + expect(botFieldService.list).not.toHaveBeenCalled() + expect(tagService.listActive).not.toHaveBeenCalled() + expect(sequenceService.list).not.toHaveBeenCalled() + expect(flowService.list).not.toHaveBeenCalled() + expect(result.inboxes).toBeUndefined() + expect(result.aiAgents).toEqual([]) + }) + + test("truncates a workspace's tags/templates at CAPABILITIES_LIST_LIMIT (200) — this LLM-facing response must never grow unbounded", async () => { + const manyTags = Array.from({ length: 250 }, (_, i) => ({ + id: `tag-${i}`, + name: `Tag ${i}`, + })) + const manyTemplates = Array.from({ length: 250 }, (_, i) => ({ + id: `tpl-${i}`, + name: `Template ${i}`, + language: "en", + status: "approved", + components: [], + })) + vi.spyOn(tagService, "listActive").mockResolvedValue(manyTags as never) + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue( + manyTemplates as never, + ) + + const result = await getCapabilities({ + workspaceId: "ws-1", + include: ["tags", "templates"], + }) + + expect(result.tags).toHaveLength(200) + expect(result.templates).toHaveLength(200) + }) +}) + +describe("getFlowAuthoringContext", () => { + test("returns only templatesByName/customFieldsByName/flowsByName, each keyed by name", async () => { + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue([ + { + id: "1001", + name: "welcome_promo", + language: "en", + status: "approved", + components: [], + }, + ] as never) + vi.spyOn(customFieldService, "list").mockResolvedValue({ + data: [{ id: "1002", name: "Plan", type: "text" }], + pageCount: 1, + } as never) + vi.spyOn(flowService, "list").mockResolvedValue({ + data: [{ id: "1003", name: "Nurture" }], + pageCount: 1, + } as never) + + const ctx = await getFlowAuthoringContext("ws-1") + + expect(Object.keys(ctx).sort()).toEqual( + ["customFieldsByName", "flowsByName", "templatesByName"].sort(), + ) + expect(ctx.templatesByName.get("welcome_promo")).toEqual({ + id: "1001", + language: "en", + status: "approved", + }) + expect(ctx.customFieldsByName.get("Plan")).toEqual({ + id: "1002", + type: "text", + }) + expect(ctx.flowsByName.get("Nurture")).toEqual({ id: "1003" }) + // Neither queried nor exposed — the compiler no longer resolves + // tag/inbox names, so gathering them here would be wasted work. + expect(inboxService.list).not.toHaveBeenCalled() + expect(tagService.listActive).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/src/capabilities/service.ts b/packages/business/src/capabilities/service.ts index 944cb4f3ac..a658b028ab 100644 --- a/packages/business/src/capabilities/service.ts +++ b/packages/business/src/capabilities/service.ts @@ -5,6 +5,7 @@ import type { } from "@chatbotx.io/flow-config" import { extractTemplateParams, + flowSpecStepTypes, waitStepDelayUnits, } from "@chatbotx.io/flow-config" import { channelTypes } from "@chatbotx.io/utils/channel" @@ -19,10 +20,10 @@ import { whatsappMessageTemplateService } from "../whatsapp-message-template/ser /** * Caps every list this service gathers. This output is fed straight into an - * LLM's context window (P2.1's design constraint, not a DB/perf one) — a - * workspace with thousands of tags or custom fields must never blow up the - * response; an agent that needs more than this can page through the - * resource's own list endpoint (`tags.list`, `customFields.list`, ...). + * LLM's context window — a workspace with thousands of tags or custom + * fields must never blow up the response; an agent that needs more than + * this can page through the resource's own list endpoint (`tags.list`, + * `customFields.list`, ...). */ const CAPABILITIES_LIST_LIMIT = 200 @@ -39,10 +40,13 @@ export const CAPABILITIES_INCLUDES = [ ] as const export type CapabilitiesInclude = (typeof CAPABILITIES_INCLUDES)[number] -// The default set returned when `include` is omitted — the flow-authoring -// essentials (P1.2's `FlowAuthoringContext` resolves against exactly these -// names). `aiAgents` is left out of the default: it's rarely needed to build -// a flow and the same information is one `ai_agents_list` call away. +// The default set returned when `include` is omitted — flow-authoring +// essentials (`FlowAuthoringContext` resolves against the `templates`, +// `customFields`, and `flows` names in here) plus the read-only reference +// lists (`inboxes`, `botFields`, `tags`, `sequences`) an agent typically +// needs alongside them. `aiAgents` is left out of the default: it's rarely +// needed to build a flow and the same information is one `ai_agents_list` +// call away. const DEFAULT_INCLUDES: readonly CapabilitiesInclude[] = [ "inboxes", "templates", @@ -83,47 +87,13 @@ export type CapabilitiesResponse = { flowSpec?: CapabilitiesFlowSpec } -// Mirrors the `.describe()` text on each `flowStepSpecSchema` member in -// `@chatbotx.io/flow-config`'s `authoring/spec-schema.ts` — kept as a short, -// hand-written summary here rather than derived from the zod schema, since -// this list is meant to be skimmed inline in `capabilities.get`'s response, -// while `GET /v1/schemas/flow-spec` (P2.2) is the full, authoritative JSON -// Schema for actually authoring a step. -const FLOW_SPEC_STEP_TYPES: CapabilitiesFlowSpecStepType[] = [ - { - type: "send", - description: - "Send one text/image/file message, optionally with up to 3 quick-reply buttons.", - }, - { - type: "sendTemplate", - description: "Send an existing WhatsApp message template by name.", - }, - { type: "wait", description: "Pause the flow for a fixed duration." }, - { - type: "branch", - description: - "Split the flow by contact-filter-style conditions (see contacts.listFilterFields).", - }, - { - type: "action", - description: - "Perform a workspace action: addTags, removeTags, setCustomField, assignConversation, or archiveConversation.", - }, - { - type: "startFlow", - description: "Start another flow for the contact, by name.", - }, - { - type: "addNote", - description: "Add an internal note to the conversation.", - }, - { - type: "goto", - description: - "Jump to an already-defined step (by its `id`) instead of continuing linearly. Must be the last step in its list.", - }, -] +function toCapabilitiesField(field: { + id: string + name: string + type: string +}): CapabilitiesField { + return { id: field.id, name: field.name, type: field.type } +} async function listInboxes(workspaceId: string): Promise { const { data } = await inboxService.list({ @@ -159,11 +129,7 @@ async function listCustomFields( workspaceId, perPage: CAPABILITIES_LIST_LIMIT, }) - return data.map((field) => ({ - id: field.id, - name: field.name, - type: field.type, - })) + return data.map(toCapabilitiesField) } async function listBotFields( @@ -173,11 +139,7 @@ async function listBotFields( workspaceId, perPage: CAPABILITIES_LIST_LIMIT, }) - return data.map((field) => ({ - id: field.id, - name: field.name, - type: field.type, - })) + return data.map(toCapabilitiesField) } async function listTags( @@ -221,7 +183,7 @@ async function listFlows( function getFlowSpecCapabilities(): CapabilitiesFlowSpec { return { - stepTypes: FLOW_SPEC_STEP_TYPES, + stepTypes: flowSpecStepTypes, waitUnits: [...waitStepDelayUnits.options], channels: [...channelTypes.options], } @@ -245,13 +207,13 @@ const CAPABILITY_LOADERS: { } /** - * Workspace capability discovery for MCP agents (P2.1) — the same shape of - * problem `listContactFilterFieldsForAPI` already solves for contact - * filters: gather every named workspace entity an agent needs to reference - * by id, in parallel, compact. Reused directly by both `GET /v1/capabilities` - * (P2.2) and the flow-spec compiler's `FlowAuthoringContext` (P1.2/P1.3) — - * the latter via `getFlowAuthoringContext` below, so the two never drift on - * what a "known template/flow/tag/custom field" is. + * Workspace capability discovery for MCP agents — the same shape of problem + * `listContactFilterFieldsForAPI` already solves for contact filters: + * gather every named workspace entity an agent needs to reference by id, in + * parallel, compact. Reused directly by both `GET /v1/capabilities` and the + * flow-spec compiler's `FlowAuthoringContext` — the latter via + * `getFlowAuthoringContext` below, so the two never drift on what a "known + * template/flow/custom field" is. */ export async function getCapabilities(props: { workspaceId: string @@ -279,10 +241,8 @@ export async function getCapabilities(props: { export async function getFlowAuthoringContext( workspaceId: string, ): Promise { - const [inboxes, templates, tags, customFields, flows] = await Promise.all([ - listInboxes(workspaceId), + const [templates, customFields, flows] = await Promise.all([ listTemplates(workspaceId), - listTags(workspaceId), listCustomFields(workspaceId), listFlows(workspaceId), ]) @@ -298,13 +258,6 @@ export async function getFlowAuthoringContext( }, ]), ), - inboxesByName: new Map( - inboxes.map((inbox) => [ - inbox.name, - { id: inbox.id, channel: inbox.channel }, - ]), - ), - tagsByName: new Map(tags.map((tag) => [tag.name, { id: tag.id }])), customFieldsByName: new Map( customFields.map((field) => [ field.name, diff --git a/packages/flow-config/__tests__/authoring/compile.test.ts b/packages/flow-config/__tests__/authoring/compile.test.ts index 76c57943dc..59932aadde 100644 --- a/packages/flow-config/__tests__/authoring/compile.test.ts +++ b/packages/flow-config/__tests__/authoring/compile.test.ts @@ -16,8 +16,6 @@ import { const emptyCtx: FlowAuthoringContext = { templatesByName: new Map(), - inboxesByName: new Map(), - tagsByName: new Map(), customFieldsByName: new Map(), flowsByName: new Map(), } @@ -26,8 +24,6 @@ const ctx: FlowAuthoringContext = { templatesByName: new Map([ ["welcome_promo", { id: "1001", language: "en", status: "approved" }], ]), - inboxesByName: new Map(), - tagsByName: new Map(), customFieldsByName: new Map([["Plan", { id: "1002", type: "text" }]]), flowsByName: new Map([["Nurture", { id: "1003" }]]), } @@ -178,11 +174,40 @@ describe("compileFlowSpec — one node per step type", () => { const node = compiled.nodes[0] expect(node?.type).toBe(nodeTypeSchema.enum.performAction) if (node?.type === "performAction") { - expect(node.data.details.steps[0]?.stepType).toBe(expectedStepType) + const compiledStep = node.data.details.steps[0] + expect(compiledStep?.stepType).toBe(expectedStepType) + if (action === "setCustomField") { + expect((compiledStep as { inputFieldId?: string })?.inputFieldId).toBe( + "1002", + ) + } } expectPublishable(compiled.nodes, compiled.edges) }) + test("action setCustomField reports an unknown custom field with candidates", () => { + try { + compileFlowSpec( + spec([ + { + type: "action", + action: "setCustomField", + customFieldName: "Pln", + value: "premium", + }, + ]), + ctx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.path).toBe("steps[0].customFieldName") + expect(authoringError?.code).toBe("unknownCustomField") + expect(authoringError?.candidates).toContain("Plan") + } + }) + test("startFlow resolves the target flow by name", () => { const compiled = compileFlowSpec( spec([{ type: "startFlow", flowName: "Nurture" }]), @@ -510,3 +535,67 @@ describe("compileFlowSpec — round-trip through the export schema", () => { expect(result.ok, result.ok ? undefined : result.reason).toBe(true) }) }) + +describe("compileFlowSpec — specPathByNodeId", () => { + test("maps a top-level step's node back to its spec path", () => { + const compiled = compileFlowSpec( + spec([{ type: "addNote", note: "Called back" }]), + emptyCtx, + ) + const node = compiled.nodes[0] + expect(node && compiled.specPathByNodeId.get(node.id)).toBe("steps[0]") + }) + + test("maps a button's nested chain to its buttons[].then path", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "send", + text: "Pick one", + buttons: [ + { + text: "Yes", + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "said yes" }], + }, + ], + }, + ]), + emptyCtx, + ) + const nestedNode = compiled.nodes.find( + (node) => node.type === nodeTypeSchema.enum.addNotes, + ) + expect(nestedNode && compiled.specPathByNodeId.get(nestedNode.id)).toBe( + "steps[0].buttons[0].then[0]", + ) + }) + + test("maps a branch case's nested chain to its cases[].then path", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "branch", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "has email" }], + }, + ], + }, + ]), + emptyCtx, + ) + const branchNode = compiled.nodes[0] + const nestedNode = compiled.nodes.find( + (node) => node.type === nodeTypeSchema.enum.addNotes, + ) + expect(branchNode && compiled.specPathByNodeId.get(branchNode.id)).toBe( + "steps[0]", + ) + expect(nestedNode && compiled.specPathByNodeId.get(nestedNode.id)).toBe( + "steps[0].cases[0].then[0]", + ) + }) +}) diff --git a/packages/flow-config/__tests__/authoring/spec-schema.test.ts b/packages/flow-config/__tests__/authoring/spec-schema.test.ts new file mode 100644 index 0000000000..afd59774ac --- /dev/null +++ b/packages/flow-config/__tests__/authoring/spec-schema.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest" +import { flowSpecStepTypes } from "../../src" + +describe("flowSpecStepTypes", () => { + test("derives one entry per flowStepSpecSchema member, each with a non-empty description", () => { + expect(flowSpecStepTypes.length).toBeGreaterThan(0) + for (const stepType of flowSpecStepTypes) { + expect(stepType.type.length).toBeGreaterThan(0) + expect(stepType.description.length).toBeGreaterThan(0) + } + }) + + test("covers every step type the DSL union declares", () => { + expect(flowSpecStepTypes.map((stepType) => stepType.type).sort()).toEqual( + [ + "action", + "addNote", + "branch", + "goto", + "send", + "sendTemplate", + "startFlow", + "wait", + ].sort(), + ) + }) +}) diff --git a/packages/flow-config/src/authoring/compile.ts b/packages/flow-config/src/authoring/compile.ts index cefdcb0083..bb24b1c2ed 100644 --- a/packages/flow-config/src/authoring/compile.ts +++ b/packages/flow-config/src/authoring/compile.ts @@ -31,24 +31,18 @@ import { waitStepDefaultFn } from "../steps/wait" import type { FlowAuthoringError, FlowAuthoringErrorCode } from "./errors" import { closestNames, FlowAuthoringException } from "./errors" import { type LayoutPosition, layoutNodes } from "./layout" -import { - type FlowSpec, - type FlowStepSpec, - TERMINAL_STEP_TYPES, -} from "./spec-schema" +import type { FlowSpec, FlowStepSpec } from "./spec-schema" /** * Reference data the compiler resolves DSL names against. Populated from the - * capabilities service (P2.1) — kept as plain `Map`s so the compiler never - * touches the database directly (this package has no such dependency). + * capabilities service — kept as plain `Map`s so the compiler never touches + * the database directly (this package has no such dependency). */ export type FlowAuthoringContext = { templatesByName: ReadonlyMap< string, { id: string; language: string; status: string } > - inboxesByName: ReadonlyMap - tagsByName: ReadonlyMap customFieldsByName: ReadonlyMap flowsByName: ReadonlyMap } @@ -57,13 +51,22 @@ export type CompiledFlow = { startNodeId: string nodes: FlowVersionSchema[] edges: EdgeSchema[] + /** Compiled node id -> the spec-relative path (e.g. `steps[2]`) that produced it. */ + specPathByNodeId: ReadonlyMap } +/** Step `type`s that end their step list — nothing may follow them. */ +const TERMINAL_STEP_TYPES: ReadonlySet = new Set([ + "branch", + "goto", +]) + type CompileState = { nodes: FlowVersionSchema[] edges: EdgeSchema[] routeUpdates: FlowRouteUpdate[] stepIdToNodeId: Map + specPathByNodeId: Map errors: FlowAuthoringError[] ctx: FlowAuthoringContext channel?: string @@ -143,8 +146,10 @@ const registerNode = ( state: CompileState, specStepId: string | undefined, node: FlowVersionSchema, + stepPath: string, ): string => { state.nodes.push(node) + state.specPathByNodeId.set(node.id, stepPath) if (specStepId) { state.stepIdToNodeId.set(specStepId, node.id) } @@ -222,7 +227,7 @@ function compileSendStep( // encounter order (this node, then whatever its buttons route to) instead // of the reverse — `node` is a reference, so mutating its `data.details` // below still updates the array element already pushed. - const nodeId = registerNode(state, step.id, node) + const nodeId = registerNode(state, step.id, node, stepPath) const buttons = (step.buttons ?? []).map((buttonSpec, buttonIndex) => compileSendButton( @@ -290,12 +295,12 @@ function compileSendTemplateStep( }) node.data.details.steps = [templateStep] - return registerNode(state, step.id, node) + return registerNode(state, step.id, node, stepPath) } function compileWaitStep( step: Extract, - _stepPath: string, + stepPath: string, state: CompileState, ): string { const waitStep = { @@ -305,12 +310,12 @@ function compileWaitStep( } const node = waitNodeDefaultFn({}) node.data.details.steps = [waitStep] - return registerNode(state, step.id, node) + return registerNode(state, step.id, node, stepPath) } function compileActionStep( step: Extract, - _stepPath: string, + stepPath: string, state: CompileState, ): string { const actionStep = (() => { @@ -322,27 +327,35 @@ function compileActionStep( ...removeContactTagStepDefaultFn(), tags: step.tagNames ?? [], } - case "setCustomField": + case "setCustomField": { + const customField = resolveCustomField( + step.customFieldName ?? "", + `${stepPath}.customFieldName`, + state, + ) return { ...setCustomFieldStepDefaultFn(), - inputFieldId: step.customFieldName ?? "", + inputFieldId: customField?.id ?? "", operation: FieldOperationType.set, value: step.value ?? "", } + } case "assignConversation": return assignConversationStepDefaultFn({ assignedId: step.assigneeId ?? "", }) case "archiveConversation": return archiveConversationStepDefaultFn() - default: - return archiveConversationStepDefaultFn() + default: { + const _exhaustive: never = step.action + throw new Error(`Unhandled action type: ${String(_exhaustive)}`) + } } })() const node = performActionNodeDefaultFn({}) node.data.details.steps = [actionStep] - return registerNode(state, step.id, node) + return registerNode(state, step.id, node, stepPath) } function compileStartFlowStep( @@ -370,18 +383,18 @@ function compileStartFlowStep( beforeStep: startExternalFlowStepDefaultFn({ flowId: targetFlow.id }), }, }) - return registerNode(state, step.id, node) + return registerNode(state, step.id, node, stepPath) } function compileAddNoteStep( step: Extract, - _stepPath: string, + stepPath: string, state: CompileState, ): string { const node = addNotesNodeDefaultFn({ detailProps: { beforeStep: addNotesStepDefaultFn({ text: step.note }) }, }) - return registerNode(state, step.id, node) + return registerNode(state, step.id, node, stepPath) } const BOT_FIELD_CONDITION_PREFIX = "botField:" @@ -400,6 +413,29 @@ type CompiledCondition = { customFieldId?: string } +/** Resolves a workspace custom field by name, recording an `unknownCustomField` error on a miss. */ +function resolveCustomField( + name: string, + path: string, + state: CompileState, +): { id: string; type: string } | null { + const customField = state.ctx.customFieldsByName.get(name) + if (!customField) { + addError( + state, + path, + "unknownCustomField", + `No custom field named "${name}" in this workspace.`, + { + hint: "Call contacts.listFilterFields and pick a custom field name from the results.", + candidates: closestNames(name, state.ctx.customFieldsByName.keys()), + }, + ) + return null + } + return customField +} + function resolveBranchCondition( condition: BranchConditionSpec, path: string, @@ -417,18 +453,8 @@ function resolveBranchCondition( if (condition.field.startsWith(CUSTOM_FIELD_CONDITION_PREFIX)) { const name = condition.field.slice(CUSTOM_FIELD_CONDITION_PREFIX.length) - const customField = state.ctx.customFieldsByName.get(name) + const customField = resolveCustomField(name, `${path}.field`, state) if (!customField) { - addError( - state, - `${path}.field`, - "unknownCustomField", - `No custom field named "${name}" in this workspace.`, - { - hint: "Call contacts.listFilterFields and pick a custom field name from the results.", - candidates: closestNames(name, state.ctx.customFieldsByName.keys()), - }, - ) return null } return { @@ -458,7 +484,7 @@ function compileBranchStep( } // Registered before compiling case/otherwise chains — see the identical // note on `compileSendStep`. - const nodeId = registerNode(state, step.id, node) + const nodeId = registerNode(state, step.id, node, stepPath) conditionStep.cases = step.cases.map((branchCase, caseIndex) => { const caseDefault = conditionCaseDefaultFn() @@ -542,8 +568,10 @@ function compileStep( return compileAddNoteStep(step, stepPath, state) case "goto": return compileGotoStep(step, stepPath, state) - default: - return null + default: { + const _exhaustive: never = step + throw new Error(`Unhandled step type: ${(step as FlowStepSpec).type}`) + } } } @@ -565,7 +593,7 @@ function compileChain( steps.forEach((step, index) => { const stepPath = `${pathPrefix}[${index}]` - const isTerminal = Boolean(TERMINAL_STEP_TYPES[step.type]) + const isTerminal = TERMINAL_STEP_TYPES.has(step.type) if (index < steps.length - 1 && isTerminal) { addError( @@ -638,6 +666,7 @@ export function compileFlowSpec( edges: [], routeUpdates: [], stepIdToNodeId: new Map(), + specPathByNodeId: new Map(), errors: [], ctx, channel: spec.channel, @@ -670,6 +699,12 @@ export function compileFlowSpec( ]) } + // `state.nodes` are `FlowVersionSchema` (this package's compiler-output + // type); `applyRouteUpdatesInNodes` operates on reactflow's generic + // `FlowNode = Node`, a structurally different + // shape (position/measured/type are generic there, not the discriminated + // union). The double cast crosses that boundary; node `id`s — what + // `specPathByNodeId` keys on below — are preserved through it either way. const routedNodes = applyRouteUpdatesInNodes( state.nodes as unknown as FlowNode[], state.routeUpdates, @@ -689,5 +724,10 @@ export function compileFlowSpec( ), ) - return { startNodeId, nodes, edges: state.edges } + return { + startNodeId, + nodes, + edges: state.edges, + specPathByNodeId: state.specPathByNodeId, + } } diff --git a/packages/flow-config/src/authoring/errors.ts b/packages/flow-config/src/authoring/errors.ts index efe1b74542..8ec3af06f5 100644 --- a/packages/flow-config/src/authoring/errors.ts +++ b/packages/flow-config/src/authoring/errors.ts @@ -1,8 +1,8 @@ import type { z } from "zod" /** - * Structured compiler diagnostics for the flow-spec DSL (P1.2). Deliberately - * NOT routed through `flowValidationCodes`/`resolveFlowValidationMessageKey` + * Structured compiler diagnostics for the flow-spec DSL. Deliberately NOT + * routed through `flowValidationCodes`/`resolveFlowValidationMessageKey` * (`../validation-codes`) — that mechanism maps a fixed set of codes to a * `messages.` i18n key across 20 locales for the builder UI. A * flow-spec authoring error is API-consumer-facing (an agent, not a person @@ -18,6 +18,7 @@ export type FlowAuthoringErrorCode = | "unknownCustomField" | "invalidGotoTarget" | "duplicateStepId" + | "invalidStep" | "compileFailed" export type FlowAuthoringError = { @@ -43,7 +44,10 @@ export class FlowAuthoringException extends Error { } } -const formatZodPathSegment = (acc: string, segment: PropertyKey): string => { +export const formatZodPathSegment = ( + acc: string, + segment: PropertyKey, +): string => { if (typeof segment === "number") { return `${acc}[${segment}]` } @@ -52,16 +56,20 @@ const formatZodPathSegment = (acc: string, segment: PropertyKey): string => { } /** - * Converts a `flowSpecSchema` parse failure straight into - * `FlowAuthoringError[]` — the issue paths are already spec-relative since - * they come from validating the spec itself, not a compiled node graph. + * Converts a `flowSpecSchema`/`publishFlowSchema` parse failure into + * `FlowAuthoringError[]`. Without `mapPath`, issue paths are used verbatim — + * correct when validating the spec itself, where paths are already + * spec-relative. `compileAndValidateSpec` (`apps/builder`) passes `mapPath` + * when validating the *compiled* node graph instead, to translate a + * node-graph path back to the spec-relative path an agent actually wrote. */ export function zodErrorToFlowAuthoringErrors( error: z.ZodError, code: FlowAuthoringErrorCode = "invalidSpec", + mapPath?: (issuePath: PropertyKey[]) => string | undefined, ): FlowAuthoringError[] { return error.issues.map((issue) => ({ - path: issue.path.reduce(formatZodPathSegment, ""), + path: mapPath?.(issue.path) ?? issue.path.reduce(formatZodPathSegment, ""), code, message: issue.message, })) diff --git a/packages/flow-config/src/authoring/layout.ts b/packages/flow-config/src/authoring/layout.ts index 3ebcd763fe..8589d60e65 100644 --- a/packages/flow-config/src/authoring/layout.ts +++ b/packages/flow-config/src/authoring/layout.ts @@ -1,3 +1,5 @@ +import type { EdgeSchema } from "../nodes/index" + // Node width/height mirror `defaultNodeData()`'s `measured` in `../nodes/base.ts` // — every node type shares the same default footprint, so layout can use one // fixed cell size instead of asking each node for its own. @@ -9,7 +11,6 @@ const COLUMN_WIDTH = NODE_WIDTH + COLUMN_GAP const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP const ORIGIN = { x: 100, y: 100 } -export type LayoutEdge = { source: string; target: string } export type LayoutPosition = { x: number; y: number } /** @@ -22,7 +23,7 @@ export type LayoutPosition = { x: number; y: number } */ export function layoutNodes( nodeIds: readonly string[], - edges: readonly LayoutEdge[], + edges: readonly Pick[], startNodeId: string, ): Map { const childrenBySource = new Map() diff --git a/packages/flow-config/src/authoring/spec-schema.ts b/packages/flow-config/src/authoring/spec-schema.ts index 71d92534b5..ed2eabf8d8 100644 --- a/packages/flow-config/src/authoring/spec-schema.ts +++ b/packages/flow-config/src/authoring/spec-schema.ts @@ -3,12 +3,11 @@ import { z } from "zod" import { waitStepDelayUnits } from "../steps/wait" /** - * The agent-facing flow DSL (P1.2 of the MCP Agent-First plan). A small, - * deliberately curated subset of the full node/step surface — the goal is an - * agent reliably building a *working* flow, not exposing every editor - * feature. Every field carries `.describe()`: this schema is also the source - * for `GET /v1/schemas/flow-spec` (P2.2), so the description IS the - * documentation an LLM sees. + * The agent-facing flow DSL. A small, deliberately curated subset of the + * full node/step surface — the goal is an agent reliably building a working + * flow, not exposing every editor feature. Every field carries `.describe()`: + * this schema is also the source for `GET /v1/schemas/flow-spec`, so the + * description IS the documentation an LLM sees. * * Hand-written recursive TS type first (`send.buttons[].then` and * `branch.cases[].then`/`.otherwise` reference the step array itself) so @@ -177,7 +176,7 @@ const branchConditionSpecSchema = z.object({ .string() .min(1) .describe( - "A static field name from `GET /v1/contacts/filter-fields`, or `customField:` / `botField:` to reference a workspace custom/bot field by name (resolved automatically — use the exact name from `contacts.listFilterFields`).", + "A static field name from `GET /v1/contacts/filter-fields`, or `customField:` to reference a workspace custom field by name (resolved automatically — use the exact name from `contacts.listFilterFields`). `botField:` is not yet supported.", ), operator: z .string() @@ -345,34 +344,35 @@ const gotoStepSpecSchema = z "Terminal — routes to an already-defined step instead of continuing. Must be the last step in its list.", ) +export const flowStepSpecOptions = [ + sendStepSpecSchema, + sendTemplateStepSpecSchema, + waitStepSpecSchema, + branchStepSpecSchema, + actionStepSpecSchema, + startFlowStepSpecSchema, + addNoteStepSpecSchema, + gotoStepSpecSchema, +] as const + export const flowStepSpecSchema: z.ZodType = z.discriminatedUnion( "type", - [ - sendStepSpecSchema, - sendTemplateStepSpecSchema, - waitStepSpecSchema, - branchStepSpecSchema, - actionStepSpecSchema, - startFlowStepSpecSchema, - addNoteStepSpecSchema, - gotoStepSpecSchema, - ], + flowStepSpecOptions, ) -/** Step `type`s that end their step list — nothing may follow them. */ -export const TERMINAL_STEP_TYPES: Record< - FlowStepSpec["type"], - true | undefined -> = { - send: undefined, - sendTemplate: undefined, - wait: undefined, - branch: true, - action: undefined, - startFlow: undefined, - addNote: undefined, - goto: true, -} +export type FlowSpecStepType = { type: string; description: string } + +/** + * Derived from each step schema's own `.describe()` — the same text `GET + * /v1/schemas/flow-spec` surfaces — rather than a hand-maintained list that + * can silently drift from the schema. + */ +export const flowSpecStepTypes: FlowSpecStepType[] = flowStepSpecOptions.map( + (option) => ({ + type: option.shape.type.value, + description: option.description ?? "", + }), +) export const flowSpecSchema = z.object({ formatVersion: z.literal(1).describe("DSL format version. Always 1."), diff --git a/packages/flow-config/src/index.ts b/packages/flow-config/src/index.ts index bfacf99b3b..c4356d03a3 100644 --- a/packages/flow-config/src/index.ts +++ b/packages/flow-config/src/index.ts @@ -1,7 +1,6 @@ // Export all definitions export * from "./authoring/compile" export * from "./authoring/errors" -export * from "./authoring/layout" export * from "./authoring/spec-schema" export * from "./channel-rules/channel-step-refinement" export * from "./channel-rules/channel-validator" From cdd9e55e4045ae5f3afecc5f86923532cc8962b4 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 08:21:43 +0700 Subject: [PATCH 03/38] fix(mcp): curate default MCP tool set and add tool descriptions Removes mcpSpec from write-heavy/less agent-friendly endpoints (broadcast create/schedule, keyword create, contact upsert/setCustomFields, tag add-by-id, custom field create, trigger create, flow validate, message create) and adds it to more read-oriented ones (AI agents/files/functions, analytics, sequences, error logs, contact tags/custom-fields/messages), plus adds `description` metadata so MCP tool discovery has better context. Refreshes the mcp-server README to match the resulting default tool list. --- .../public-spec-mcp.test.ts.snap | 43 +++++----- .../src/features/ai-agents/api/public.ts | 7 ++ .../src/features/ai-files/api/public.ts | 3 + .../src/features/ai-functions/api/public.ts | 3 + .../src/features/analytics/api/public.ts | 15 +++- .../features/automated-response/api/public.ts | 1 - .../src/features/broadcasts/api/public.ts | 4 +- .../src/features/contact-filter/api/public.ts | 2 - .../features/contact-sequences/api/public.ts | 3 + .../src/features/contacts/api/public/crud.ts | 3 - .../contacts/api/public/custom-fields.ts | 7 +- .../features/contacts/api/public/messages.ts | 3 + .../src/features/contacts/api/public/tags.ts | 5 +- .../src/features/conversations/api/public.ts | 1 - .../src/features/custom-fields/api/public.ts | 3 - .../src/features/error-logs/api/public.ts | 4 + apps/builder/src/features/flows/api/public.ts | 2 +- .../src/features/inboxes/api/public.ts | 2 - .../src/features/messages/api/public.ts | 1 - .../src/features/sequences/api/public.ts | 5 +- apps/builder/src/features/tags/api/public.ts | 3 - .../src/features/triggers/api/public.ts | 3 - apps/mcp-server/README.md | 79 ++++++++----------- 23 files changed, 108 insertions(+), 94 deletions(-) diff --git a/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap index 26fca37407..ef5aa45c40 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap @@ -2,49 +2,48 @@ exports[`default tool set > operation ids match the curated snapshot 1`] = ` [ - "analytics.contactsCount", - "analytics.newContactsCount", - "broadcasts.create", + "aiAgents.create", + "aiAgents.list", + "aiAgents.update", + "aiFiles.list", + "aiFunctions.list", + "analytics.blockedContactsPerDay", + "analytics.broadcastStats", + "analytics.flowStats", + "analytics.newContactCountsPerDay", + "analytics.sequenceStepStats", + "broadcasts.get", "broadcasts.list", - "broadcasts.schedule", "broadcasts.stop", "capabilities.get", - "contacts.addTags", - "contacts.count", + "contacts.addTagsByName", "contacts.create", "contacts.get", "contacts.list", - "contacts.listFilterFields", + "contacts.listCustomFields", + "contacts.listMessages", + "contacts.listSequences", + "contacts.listTags", "contacts.search", "contacts.sendFlow", "contacts.sendMessage", - "contacts.setCustomFields", + "contacts.setCustomField", "contacts.subscribeSequences", - "contacts.update", - "contacts.upsert", - "conversations.archive", "conversations.assign", "conversations.get", "conversations.list", - "customFields.create", - "customFields.list", + "errorLogs.list", "flows.create", "flows.get", "flows.list", "flows.publish", - "flows.validate", - "inboxes.list", - "keywords.create", + "flows.updateDraft", "keywords.list", - "messages.create", "messages.list", "schemas.flowSpec", - "sequences.create", + "sequences.get", "sequences.list", - "tags.create", - "tags.list", + "sequences.update", "token.get", - "triggers.create", - "triggers.list", ] `; diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 202eacf62d..9f72493a70 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -2,6 +2,7 @@ import { aiAgentService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -23,7 +24,9 @@ export const aiAgentsPublicRouter = { method: "GET", path: "/v1/ai-agents", summary: "List AI agents", + description: "Lists AI agents configured in the workspace.", tags: ["AI Agents"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(listAIAgentsResponse) @@ -62,8 +65,10 @@ export const aiAgentsPublicRouter = { method: "POST", path: "/v1/ai-agents", summary: "Create an AI agent", + description: "Creates a new AI agent in the workspace.", successStatus: 201, tags: ["AI Agents"], + spec: mcpSpec({ visibility: "default" }), }) .input(createAIAgentRequest) .output(aiAgentResourceSchema) @@ -78,7 +83,9 @@ export const aiAgentsPublicRouter = { method: "PUT", path: "/v1/ai-agents/{id}", summary: "Update an AI agent", + description: "Partially updates an existing AI agent.", tags: ["AI Agents"], + spec: mcpSpec({ visibility: "default" }), }) .input(updateAIAgentRequest.and(z.object({ id: zodBigintAsString() }))) .output(aiAgentResourceSchema) diff --git a/apps/builder/src/features/ai-files/api/public.ts b/apps/builder/src/features/ai-files/api/public.ts index e90262fb6f..46af4957f7 100644 --- a/apps/builder/src/features/ai-files/api/public.ts +++ b/apps/builder/src/features/ai-files/api/public.ts @@ -2,6 +2,7 @@ import { aiFileService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -23,7 +24,9 @@ export const aiFilesPublicRouter = { method: "GET", path: "/v1/ai-files", summary: "List AI files", + description: "Lists files uploaded to the workspace's AI knowledge base.", tags: ["AI Files"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListResponse(publicAIFileResource)) diff --git a/apps/builder/src/features/ai-functions/api/public.ts b/apps/builder/src/features/ai-functions/api/public.ts index 46723f677e..dd97175019 100644 --- a/apps/builder/src/features/ai-functions/api/public.ts +++ b/apps/builder/src/features/ai-functions/api/public.ts @@ -2,6 +2,7 @@ import { aiFunctionService } from "@chatbotx.io/business" import { notFoundException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -25,7 +26,9 @@ export const aiFunctionsPublicRouter = { method: "GET", path: "/v1/ai-functions", summary: "List AI functions", + description: "Lists AI functions (tools) configured in the workspace.", tags: ["AI Functions"], + spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListResponse(aiFunctionResource)) diff --git a/apps/builder/src/features/analytics/api/public.ts b/apps/builder/src/features/analytics/api/public.ts index e0cd233e56..accebf5267 100644 --- a/apps/builder/src/features/analytics/api/public.ts +++ b/apps/builder/src/features/analytics/api/public.ts @@ -109,7 +109,10 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/new-contact-counts-per-day", summary: "Get new contact counts per day", + description: + "Returns the count of contacts first created on each day within the given `from`/`to` time range.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactCountsPublicResponse) @@ -127,7 +130,10 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/blocked-contacts-per-day", summary: "Get blocked contacts per day", + description: + "Returns the count of contacts blocked on each day within the given `from`/`to` time range.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactCountsPublicResponse) @@ -179,7 +185,6 @@ export const analyticsPublicRouter = { description: "Counts contacts first created within the given `from`/`to` time range.", tags: ["Analytics"], - spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactsCountPublicResponse) @@ -213,7 +218,6 @@ export const analyticsPublicRouter = { description: "Counts all contacts that existed at any point within the given `from`/`to` time range.", tags: ["Analytics"], - spec: mcpSpec({ visibility: "default" }), }) .input(timeRangePublicRequest) .output(contactsCountPublicResponse) @@ -542,7 +546,10 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/broadcasts/{broadcastId}/stats", summary: "Get broadcast stats", + description: + "Returns delivery stats (sent/delivered/read/failed counts) for a single broadcast.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(broadcastStatsPublicRequest) .output(broadcastStatsPublicResponse) @@ -565,7 +572,9 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/sequences/{sequenceId}/steps/{stepId}/stats", summary: "Get sequence step stats", + description: "Returns delivery stats for a single sequence step.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(sequenceStepStatsPublicRequest) .output(sequenceStepStatsPublicResponse) @@ -606,7 +615,9 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/flows/{flowId}", summary: "Get flow analytics", + description: "Returns session/completion stats for a single flow.", tags: ["Analytics"], + spec: mcpSpec({ visibility: "default" }), }) .input(flowStatsPublicRequest) .output(flowStatsPublicResponse) diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index 965e4de938..89f8d673f4 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -79,7 +79,6 @@ export const keywordsPublicRouter = { "Creates a keyword automation that replies with text or starts a flow when any of `keywords` is matched in an inbound message or comment.", successStatus: 201, tags: ["Keywords"], - spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index 987b2b9fe4..f5197f69ef 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -79,7 +79,9 @@ export const broadcastsPublicRouter = { method: "GET", path: "/v1/broadcasts/{idOrName}", summary: "Get broadcast by id or name", + description: "Returns a single broadcast looked up by id or name.", tags: ["Broadcasts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ idOrName: z.string() })) .output(publicBroadcastResource) @@ -156,7 +158,6 @@ export const broadcastsPublicRouter = { "Creates a broadcast as a draft (or immediately scheduled, depending on the payload) targeting the given audience filter.", successStatus: 201, tags: ["Broadcasts"], - spec: mcpSpec({ visibility: "default" }), }) .input(createBroadcastRequest) .output(publicBroadcastResource) @@ -225,7 +226,6 @@ export const broadcastsPublicRouter = { summary: "Schedule a draft broadcast", description: "Only matches a broadcast whose status is draft.", tags: ["Broadcasts"], - spec: mcpSpec({ visibility: "default" }), }) .input(scheduleBroadcastSchema.and(z.object({ id: zodBigintAsString() }))) .output(z.object({ id: z.string() })) diff --git a/apps/builder/src/features/contact-filter/api/public.ts b/apps/builder/src/features/contact-filter/api/public.ts index 74b78de9a3..da30710594 100644 --- a/apps/builder/src/features/contact-filter/api/public.ts +++ b/apps/builder/src/features/contact-filter/api/public.ts @@ -1,6 +1,5 @@ import { listContactFilterFieldsForAPI } from "@/features/contact-filter/lib/list-contact-filter-fields" import { listContactFilterFieldsPublicResponse } from "@/features/contact-filter/schema/public" -import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" import { workspaceTokenAuthAPIForScope } from "@/orpc" @@ -15,7 +14,6 @@ export const contactsFilterFieldsPublicRouter = { description: "Returns the static fields available for `contactFilter` conditions (with each field's supported operators), plus the workspace's actual custom fields, bot fields, and tags so a filter condition can reference a real id/name instead of guessing one. Use this before building a `contactFilter` for `contacts.search` or `contacts.count`.", tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .output(listContactFilterFieldsPublicResponse) .errors(possibleErrorsOnListingResource) diff --git a/apps/builder/src/features/contact-sequences/api/public.ts b/apps/builder/src/features/contact-sequences/api/public.ts index 9bd381d66a..76ba344253 100644 --- a/apps/builder/src/features/contact-sequences/api/public.ts +++ b/apps/builder/src/features/contact-sequences/api/public.ts @@ -22,7 +22,10 @@ export const contactsSequencesPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/sequences", summary: "List sequences the contact is enrolled in", + description: + "Lists the sequences the contact identified by `identifier` is currently enrolled in.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ identifier: z.string().min(1) })) .output(listContactSequencesPublicResponse) diff --git a/apps/builder/src/features/contacts/api/public/crud.ts b/apps/builder/src/features/contacts/api/public/crud.ts index 5273e4c7a5..401e124f54 100644 --- a/apps/builder/src/features/contacts/api/public/crud.ts +++ b/apps/builder/src/features/contacts/api/public/crud.ts @@ -93,7 +93,6 @@ export const contactsCrudPublicRouter = { description: "Counts contacts matching the same filter shape as `contacts.list`/`contacts.search`, without paginating the rows.", tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .input(countContactsPublicRequest) .output(countContactsPublicResponse) @@ -207,7 +206,6 @@ export const contactsCrudPublicRouter = { "Overwrites the given standard and/or custom fields on the contact identified by `identifier`; fields omitted from the body are left unchanged.", successStatus: 204, tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .input( z @@ -299,7 +297,6 @@ export const contactsCrudPublicRouter = { description: "Creates the contact identified by `identifier` if it doesn't exist yet, otherwise updates the given fields on the existing one.", tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/custom-fields.ts b/apps/builder/src/features/contacts/api/public/custom-fields.ts index b57e6dd505..b9b358c066 100644 --- a/apps/builder/src/features/contacts/api/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/api/public/custom-fields.ts @@ -32,7 +32,10 @@ export const contactsCustomFieldsPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/custom-fields", summary: "Get all custom fields from a contact", + description: + "Lists every custom field value set on the contact identified by `identifier`.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ identifier: z.string().min(1) })) .output(listPublicContactCustomFieldsResponse) @@ -80,7 +83,10 @@ export const contactsCustomFieldsPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/custom-fields/{customFieldId}", summary: "Set contact custom field value", + description: + "Sets a single custom field's value on the contact identified by `identifier`. Use `contacts.setCustomFields` to set several at once.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ @@ -112,7 +118,6 @@ export const contactsCustomFieldsPublicRouter = { "Sets each given custom field to its value on the contact identified by `identifier`; fields not listed are left unchanged. Use `customFields.list`/`customFields.create` first to resolve names to ids.", successStatus: 204, tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/messages.ts b/apps/builder/src/features/contacts/api/public/messages.ts index 70d8525b81..fe1cb6925f 100644 --- a/apps/builder/src/features/contacts/api/public/messages.ts +++ b/apps/builder/src/features/contacts/api/public/messages.ts @@ -69,7 +69,10 @@ export const contactsMessagesPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/messages", summary: "List messages for contact", + description: + "Lists messages on the contact's existing conversation, newest-related pagination via `cursor`.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ diff --git a/apps/builder/src/features/contacts/api/public/tags.ts b/apps/builder/src/features/contacts/api/public/tags.ts index d1334da7f1..b00a64d536 100644 --- a/apps/builder/src/features/contacts/api/public/tags.ts +++ b/apps/builder/src/features/contacts/api/public/tags.ts @@ -23,7 +23,10 @@ export const contactsTagsPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/tags", summary: "Get all tags added to this contact", + description: + "Lists every tag attached to the contact identified by `identifier`.", tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ identifier: z.string().min(1) })) .output(z.object({ data: z.array(publicTagResource) })) @@ -48,7 +51,6 @@ export const contactsTagsPublicRouter = { "Attaches the given tag ids to the contact identified by `identifier`; tags already on the contact are left as-is. Use `tags.list`/`tags.create` first to resolve names to ids.", successStatus: 204, tags: ["Contacts"], - spec: mcpSpec({ visibility: "default" }), }) .input( z.object({ @@ -105,6 +107,7 @@ export const contactsTagsPublicRouter = { 'Same as `addTags` but takes tag names instead of ids — existing tags whose name matches are reused, unmatched names are created. Use this when you know the tag name but not its id (call `tags.list` first only if you need the id back). Example: `{"tags":["VIP"]}`.', successStatus: 204, tags: ["Contacts"], + spec: mcpSpec({ visibility: "default" }), }) .input(addTagsByNamePublicRequest) .errors(possibleErrorsOnMutatingResource) diff --git a/apps/builder/src/features/conversations/api/public.ts b/apps/builder/src/features/conversations/api/public.ts index 6782371d28..acb5dde5dd 100644 --- a/apps/builder/src/features/conversations/api/public.ts +++ b/apps/builder/src/features/conversations/api/public.ts @@ -161,7 +161,6 @@ export const conversationsPublicRouter = { description: "Archives the conversation, removing it from the default inbox view. Use `conversations.list` with the appropriate filter to find archived conversations again.", tags: ["Conversations"], - spec: mcpSpec({ visibility: "default" }), }) .input(conversationIdPathParam) .output(successResponse) diff --git a/apps/builder/src/features/custom-fields/api/public.ts b/apps/builder/src/features/custom-fields/api/public.ts index 61fbfa0608..3f66825596 100644 --- a/apps/builder/src/features/custom-fields/api/public.ts +++ b/apps/builder/src/features/custom-fields/api/public.ts @@ -1,7 +1,6 @@ import { customFieldService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" -import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -29,7 +28,6 @@ export const customFieldsPublicRouter = { description: "Lists every custom field defined in the workspace, with its id and type. Use `contacts.setCustomFields` to set values on a contact.", tags: ["Custom Fields"], - spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(listPublicCustomFieldsResponse) @@ -51,7 +49,6 @@ export const customFieldsPublicRouter = { "Defines a new custom field on the workspace with the given name and value type.", successStatus: 201, tags: ["Custom Fields"], - spec: mcpSpec({ visibility: "default" }), }) .input(createCustomFieldRequest.pick({ name: true, type: true })) .output(publicCustomFieldResource) diff --git a/apps/builder/src/features/error-logs/api/public.ts b/apps/builder/src/features/error-logs/api/public.ts index 2d4748e861..db64e906e5 100644 --- a/apps/builder/src/features/error-logs/api/public.ts +++ b/apps/builder/src/features/error-logs/api/public.ts @@ -1,4 +1,5 @@ import { listErrorLogs } from "@chatbotx.io/business/error-log" +import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" import { withPublicPaging } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" @@ -15,7 +16,10 @@ export const errorLogsPublicRouter = { method: "GET", path: "/v1/error-logs", summary: "List error logs", + description: + "Lists error logs recorded in the workspace, filterable by `keyword`, newest first.", tags: ["Error Logs"], + spec: mcpSpec({ visibility: "default" }), }) // `sort` is dropped, unlike the private table, and the order is pinned in // the handler instead — the same way the tags/bot-fields/broadcasts public diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index 4d6dd8aa8c..0d11bab986 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -192,7 +192,6 @@ export const flowsPublicRouter = { description: "Compiles a flow-spec DSL object (see `GET /v1/schemas/flow-spec`) and validates the result exactly like `flows.publish` would, without persisting anything. On success, returns the compiled node/edge graph. On failure, returns a 422 with structured errors (`path`/`code`/`message`/`hint`/`candidates`) — fix and retry before calling `flows.publish`.", tags: ["Flows"], - spec: mcpSpec({ visibility: "default" }), }) .input(flowSpecRequest) .output(publishFlowSchema) @@ -209,6 +208,7 @@ export const flowsPublicRouter = { description: "Overwrites the draft version's nodes/edges in place, without publishing. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` compiled server-side into that same graph — draft nodes are not otherwise validated (see `flows.validate` to check a spec before writing it).", tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) .input(updateDraftFlowRequest.and(z.object({ id: zodBigintAsString() }))) .errors(possibleErrorsOnMutatingResource) diff --git a/apps/builder/src/features/inboxes/api/public.ts b/apps/builder/src/features/inboxes/api/public.ts index 96cd005eff..2726e57af7 100644 --- a/apps/builder/src/features/inboxes/api/public.ts +++ b/apps/builder/src/features/inboxes/api/public.ts @@ -1,4 +1,3 @@ -import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { listInboxes } from "../queries" @@ -19,7 +18,6 @@ export const inboxesPublicRouter = { description: "List connected inboxes with their internal IDs. Use `id` as the `inboxId` parameter when sending messages or flows to a contact.", tags: ["Channels"], - spec: mcpSpec({ visibility: "default" }), }) .input(publishInboxesRequest) .output(publicListInboxResponse) diff --git a/apps/builder/src/features/messages/api/public.ts b/apps/builder/src/features/messages/api/public.ts index c6a5ec02f7..c6ab53ddda 100644 --- a/apps/builder/src/features/messages/api/public.ts +++ b/apps/builder/src/features/messages/api/public.ts @@ -114,7 +114,6 @@ export const messagesPublicRouter = { "Sends an outgoing text/media message on an existing conversation. To message a contact without first resolving their conversation id, use `contacts.sendMessage` instead.", successStatus: 201, tags: ["Messages"], - spec: mcpSpec({ visibility: "default" }), }) .input(createMessageRequest.and(conversationIdPathParam)) .output(messageResourceWithRelations.nullable()) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index a992bb15b4..56d2100215 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -52,7 +52,9 @@ export const sequencesPublicRouter = { method: "GET", path: "/v1/sequences/{id}", summary: "Get sequence details", + description: "Returns a sequence with its list of steps.", tags: ["Sequences"], + spec: mcpSpec({ visibility: "default" }), }) .input(z.object({ id: z.string() })) .output(sequenceResource) @@ -74,7 +76,6 @@ export const sequencesPublicRouter = { "Creates an empty sequence. Add steps afterward via the builder UI or `sequences.upsertStep`.", successStatus: 201, tags: ["Sequences"], - spec: mcpSpec({ visibility: "default" }), }) .input(createSequenceRequest) .output(z.object({ sequenceId: z.string() })) @@ -92,7 +93,9 @@ export const sequencesPublicRouter = { method: "PATCH", path: "/v1/sequences/{id}", summary: "Update a sequence's name or active state", + description: "Partially updates a sequence's name or active flag.", tags: ["Sequences"], + spec: mcpSpec({ visibility: "default" }), }) .input(updateSequenceSchema.and(z.object({ id: zodBigintAsString() }))) .errors(possibleErrorsOnMutatingResource) diff --git a/apps/builder/src/features/tags/api/public.ts b/apps/builder/src/features/tags/api/public.ts index facc054497..a693b8e34c 100644 --- a/apps/builder/src/features/tags/api/public.ts +++ b/apps/builder/src/features/tags/api/public.ts @@ -1,7 +1,6 @@ import { tagService } from "@chatbotx.io/business" import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" -import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -26,7 +25,6 @@ export const tagsPublicRouter = { description: "Lists every tag in the workspace. Use `tags.create` to add one, or `contacts.addTags` to attach existing ones to a contact.", tags: ["Tags"], - spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListTagsResponse) @@ -48,7 +46,6 @@ export const tagsPublicRouter = { description: "Creates a new tag in the workspace, returned with its id.", successStatus: 201, tags: ["Tags"], - spec: mcpSpec({ visibility: "default" }), }) .input(createTagRequest.pick({ name: true })) .output(publicTagResource) diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 0d3e7ea9a1..8433174402 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -4,7 +4,6 @@ import { folderTypes } from "@chatbotx.io/database/partials" import type { TriggerModel } from "@chatbotx.io/database/types" import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" -import { mcpSpec } from "@/lib/orpc/mcp-annotations" import { possibleErrorsOnCreatingResource, possibleErrorsOnDeletingResource, @@ -42,7 +41,6 @@ export const triggersPublicRouter = { summary: "List triggers", description: "Lists triggers with their real conditions and actions.", tags: ["Triggers"], - spec: mcpSpec({ visibility: "default" }), }) .input(publicListRequest) .output(publicListResponse(triggerResource)) @@ -87,7 +85,6 @@ export const triggersPublicRouter = { "Creates an empty trigger. Use PUT /v1/triggers/{id} to attach conditions and actions.", successStatus: 201, tags: ["Triggers"], - spec: mcpSpec({ visibility: "default" }), }) .input(createTriggerSchema) .output(triggerResource) diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 6220909646..16769900e8 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -36,7 +36,7 @@ Use `search_tools` when the task needs something outside the default set (e.g. d ## Available tools -Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). The current default set has 44 tools: +Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). The current default set has 43 tools: ### Capabilities @@ -46,108 +46,97 @@ Tool names are derived from the OpenAPI `operationId` converted to `snake_case` | `schemas_flow_spec` | Get the JSON Schema for the flow-spec DSL | | `token_get` | Get the calling token's workspace id, permission, and scopes | +### AI Agents + +| Tool | Description | +|---|---| +| `ai_agents_list` | List AI agents | +| `ai_agents_create` | Create an AI agent | +| `ai_agents_update` | Update an AI agent | +| `ai_files_list` | List AI files | +| `ai_functions_list` | List AI functions | + ### Analytics | Tool | Description | |---|---| -| `analytics_contacts_count` | Get contacts count | -| `analytics_new_contacts_count` | Get new contacts count | +| `analytics_new_contact_counts_per_day` | Get new contact counts per day | +| `analytics_blocked_contacts_per_day` | Get blocked contacts per day | +| `analytics_flow_stats` | Get flow analytics | +| `analytics_broadcast_stats` | Get broadcast stats | +| `analytics_sequence_step_stats` | Get sequence step stats | ### Broadcasts | Tool | Description | |---|---| -| `broadcasts_create` | Create a broadcast | | `broadcasts_list` | Get all broadcasts | -| `broadcasts_schedule` | Schedule a draft broadcast | +| `broadcasts_get` | Get broadcast by id or name | | `broadcasts_stop` | Stop a broadcast that is currently sending | ### Contacts | Tool | Description | |---|---| -| `contacts_add_tags` | Add tags to the contact | -| `contacts_count` | Count contacts matching a filter | | `contacts_create` | Create a contact | | `contacts_get` | Get contact by identifier (id:123, email:user@example.com, phone:+84...) | | `contacts_list` | List contacts | -| `contacts_list_filter_fields` | List every field usable in a contact filter | | `contacts_search` | Search contacts with a filter body | -| `contacts_send_flow` | Send flow to contact | +| `contacts_list_tags` | Get all tags added to this contact | +| `contacts_add_tags_by_name` | Add tags to the contact by name | +| `contacts_list_custom_fields` | Get all custom fields from a contact | +| `contacts_set_custom_field` | Set contact custom field value | +| `contacts_list_messages` | List messages for contact | | `contacts_send_message` | Send message to contact | -| `contacts_set_custom_fields` | Set multiple custom field values for a contact | +| `contacts_send_flow` | Send flow to contact | +| `contacts_list_sequences` | List sequences the contact is enrolled in | | `contacts_subscribe_sequences` | Enroll the contact in one or more sequences | -| `contacts_update` | Update contact fields | -| `contacts_upsert` | Upsert a contact by identifier | ### Conversations | Tool | Description | |---|---| -| `conversations_archive` | Archive a conversation | -| `conversations_assign` | Assign or unassign a conversation to a user or inbox team | -| `conversations_get` | Get a conversation by id | | `conversations_list` | List conversations | +| `conversations_get` | Get a conversation by id | +| `conversations_assign` | Assign or unassign a conversation to a user or inbox team | -### Custom Fields +### Error Logs | Tool | Description | |---|---| -| `custom_fields_create` | Create a custom field | -| `custom_fields_list` | Get all custom fields | +| `error_logs_list` | List error logs | ### Flows | Tool | Description | |---|---| -| `flows_create` | Create a flow | -| `flows_get` | Get a flow by id | | `flows_list` | List flows | +| `flows_get` | Get a flow by id | +| `flows_create` | Create a flow | +| `flows_update_draft` | Update a flow's draft version | | `flows_publish` | Publish a flow | -| `flows_validate` | Compile and validate a flow spec without publishing it | - -### Inboxes - -| Tool | Description | -|---|---| -| `inboxes_list` | List inboxes | ### Keywords | Tool | Description | |---|---| -| `keywords_create` | Create a keyword automation | | `keywords_list` | List keywords (automated responses) | ### Messages | Tool | Description | |---|---| -| `messages_create` | Send a message on a conversation | | `messages_list` | List messages on a conversation | ### Sequences | Tool | Description | |---|---| -| `sequences_create` | Create a sequence | | `sequences_list` | List sequences | +| `sequences_get` | Get sequence details | +| `sequences_update` | Update a sequence's name or active state | -### Tags - -| Tool | Description | -|---|---| -| `tags_create` | Create a new tag | -| `tags_list` | Get all tags | - -### Triggers - -| Tool | Description | -|---|---| -| `triggers_create` | Create a trigger | -| `triggers_list` | List triggers | - -Everything else — deletes, less-common resources (AI agents, coupons, products, webhooks, saved replies, error logs, integrations, workspace members, etc.), and channel-token-only or deprecated operations — is reachable via `search_tools` → `call_tool`, not `tools/list`. +Everything else — deletes, less-common resources (coupons, products, webhooks, saved replies, tags/triggers/inboxes/custom-fields management, integrations, workspace members, etc.), and channel-token-only or deprecated operations — is reachable via `search_tools` → `call_tool`, not `tools/list`. ## Prerequisites From ac261cf3c33582f21de80b679c55a51f498b9232 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 09:32:26 +0700 Subject: [PATCH 04/38] refactor(flow-config): dedupe compile pipeline and cover flow-spec schema Splits compileFlowSpec into createCompileState/validateStructure/compileChain/finalizeGraph, extracts resolveByName to replace three duplicated lookup blocks, builds nodes immutably before registerNode instead of post-hoc mutation, makes applyRouteUpdatesInNodes generic, gates sendTemplate steps on APPROVED template status, describes every flow-spec schema field, drops unused button/case id fields, tightens the authoring package's public export surface, and adds layout/errors/compile edge-case test coverage. --- .../__tests__/authoring/compile.test.ts | 206 +++++++- .../__tests__/authoring/errors.test.ts | 89 ++++ .../__tests__/authoring/layout.test.ts | 43 ++ .../__tests__/authoring/spec-schema.test.ts | 237 ++++++++- packages/flow-config/src/authoring/compile.ts | 468 +++++++++++------- packages/flow-config/src/authoring/errors.ts | 26 +- packages/flow-config/src/authoring/layout.ts | 10 +- .../flow-config/src/authoring/spec-schema.ts | 226 +++++---- packages/flow-config/src/index.ts | 22 +- packages/flow-config/src/nodes/base.ts | 4 +- packages/flow-config/src/routable-handle.ts | 15 +- 11 files changed, 1032 insertions(+), 314 deletions(-) create mode 100644 packages/flow-config/__tests__/authoring/errors.test.ts create mode 100644 packages/flow-config/__tests__/authoring/layout.test.ts diff --git a/packages/flow-config/__tests__/authoring/compile.test.ts b/packages/flow-config/__tests__/authoring/compile.test.ts index 59932aadde..bb6a547154 100644 --- a/packages/flow-config/__tests__/authoring/compile.test.ts +++ b/packages/flow-config/__tests__/authoring/compile.test.ts @@ -22,7 +22,7 @@ const emptyCtx: FlowAuthoringContext = { const ctx: FlowAuthoringContext = { templatesByName: new Map([ - ["welcome_promo", { id: "1001", language: "en", status: "approved" }], + ["welcome_promo", { id: "1001", language: "en", status: "APPROVED" }], ]), customFieldsByName: new Map([["Plan", { id: "1002", type: "text" }]]), flowsByName: new Map([["Nurture", { id: "1003" }]]), @@ -105,7 +105,9 @@ describe("compileFlowSpec — one node per step type", () => { test("sendTemplate resolves the template by name", () => { const compiled = compileFlowSpec( - spec([{ type: "sendTemplate", templateName: "welcome_promo" }]), + spec([{ type: "sendTemplate", templateName: "welcome_promo" }], { + channel: "messenger", + }), ctx, ) const node = compiled.nodes[0] @@ -120,6 +122,28 @@ describe("compileFlowSpec — one node per step type", () => { expectPublishable(compiled.nodes, compiled.edges) }) + test("send uses the flow channel or defaults to omnichannel", () => { + const whatsapp = compileFlowSpec( + spec([{ type: "send", text: "Hello!" }], { channel: "whatsapp" }), + emptyCtx, + ) + const omnichannel = compileFlowSpec( + spec([{ type: "send", text: "Hello!" }]), + emptyCtx, + ) + + expect( + whatsapp.nodes[0]?.type === "sendMessage" + ? whatsapp.nodes[0].data.details.beforeStep.channel + : undefined, + ).toBe("whatsapp") + expect( + omnichannel.nodes[0]?.type === "sendMessage" + ? omnichannel.nodes[0].data.details.beforeStep.channel + : undefined, + ).toBe("omnichannel") + }) + test("sendTemplate reports an unknown template with candidates", () => { try { compileFlowSpec( @@ -285,6 +309,90 @@ describe("compileFlowSpec — one node per step type", () => { } expectPublishable(compiled.nodes, compiled.edges) }) + + test("rejects bot fields in branch conditions", () => { + try { + compileFlowSpec( + spec([ + { + type: "branch", + cases: [ + { + when: [{ field: "botField:email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", note: "has email" }], + }, + ], + }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("invalidSpec") + expect(authoringError?.path).toBe("steps[0].cases[0].when[0].field") + } + }) +}) + +describe("compileFlowSpec — reference resolution", () => { + test("accumulates invalid names in spec order", () => { + try { + compileFlowSpec( + spec([ + { type: "sendTemplate", templateName: "missing template" }, + { + type: "action", + action: "setCustomField", + customFieldName: "missing field", + value: "value", + }, + { type: "startFlow", flowName: "missing flow" }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + expect( + (error as FlowAuthoringException).errors.map(({ code, path }) => ({ + code, + path, + })), + ).toEqual([ + { code: "unknownTemplate", path: "steps[0].templateName" }, + { code: "unknownCustomField", path: "steps[1].customFieldName" }, + { code: "unknownFlow", path: "steps[2].flowName" }, + ]) + } + }) + + test("rejects a resolved template that is not approved", () => { + const pendingTemplateCtx: FlowAuthoringContext = { + ...emptyCtx, + templatesByName: new Map([ + ["pending", { id: "1004", language: "en", status: "PENDING" }], + ]), + } + + try { + compileFlowSpec( + spec([{ type: "sendTemplate", templateName: "pending" }]), + pendingTemplateCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("templateNotApproved") + expect(authoringError?.path).toBe("steps[0].templateName") + expect(authoringError?.hint).toBe( + "Pick a template whose status is APPROVED in capabilities.get's templates list.", + ) + } + }) }) describe("compileFlowSpec — a realistic multi-step flow", () => { @@ -402,12 +510,51 @@ describe("compileFlowSpec — goto", () => { edge.source === waitNode?.id && edge.sourceHandle === waitNode?.id, ) expect(gotoEdge?.target).toBe(greetNode?.id) + for (const node of compiled.nodes) { + expect(node.position.x).toEqual(expect.any(Number)) + expect(node.position.y).toEqual(expect.any(Number)) + } + }) + + test("wires a nested chain opening with goto directly to its target", () => { + const compiled = compileFlowSpec( + spec([ + { type: "addNote", id: "target", note: "target" }, + { + type: "branch", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "goto", targetId: "target" }], + }, + ], + }, + ]), + emptyCtx, + ) + + expect(compiled.nodes).toHaveLength(2) + const [targetNode, branchNode] = compiled.nodes + if (branchNode?.type !== "condition") { + throw new Error("expected condition node") + } + const caseId = branchNode.data.details.steps[0]?.cases[0]?.id + expect( + compiled.edges.find((edge) => edge.sourceHandle === caseId)?.target, + ).toBe(targetNode?.id) }) test("rejects goto as the first step", () => { - expect(() => - compileFlowSpec(spec([{ type: "goto", targetId: "x" }]), emptyCtx), - ).toThrow(FlowAuthoringException) + try { + compileFlowSpec(spec([{ type: "goto", targetId: "x" }]), emptyCtx) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("invalidFirstStep") + expect(authoringError?.path).toBe("steps[0]") + } }) test("rejects a goto to an unknown step id", () => { @@ -473,6 +620,55 @@ describe("compileFlowSpec — structural validation", () => { expect(codes).toEqual(["duplicateStepId", "duplicateStepId"]) } }) + + test("reports duplicate ids across nesting levels", () => { + try { + compileFlowSpec( + spec([ + { + type: "branch", + id: "dup", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "addNote", id: "dup", note: "nested" }], + }, + ], + }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + expect( + (error as FlowAuthoringException).errors.map(({ code, path }) => ({ + code, + path, + })), + ).toEqual([ + { code: "duplicateStepId", path: "steps[0]" }, + { + code: "duplicateStepId", + path: "steps[0].cases[0].then[0]", + }, + ]) + } + }) + + test("guards the compiler invariant when an unparsed spec has no steps", () => { + // `FlowSpec` cannot encode flowSpecSchema's runtime `.min(1)` constraint. + try { + compileFlowSpec(spec([]), emptyCtx) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("compileFailed") + expect(authoringError?.path).toBe("steps") + } + }) }) describe("compileFlowSpec — layout determinism", () => { diff --git a/packages/flow-config/__tests__/authoring/errors.test.ts b/packages/flow-config/__tests__/authoring/errors.test.ts new file mode 100644 index 0000000000..0a5ae89dc6 --- /dev/null +++ b/packages/flow-config/__tests__/authoring/errors.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest" +import { z } from "zod" +import { + closestNames, + formatZodPathSegment, + zodErrorToFlowAuthoringErrors, +} from "../../src/authoring/errors" + +const getInvalidSpecError = () => { + const result = z + .object({ items: z.array(z.object({ name: z.string().min(3) })) }) + .safeParse({ items: [{ name: "" }] }) + if (result.success) { + throw new Error("expected schema parsing to fail") + } + return result.error +} + +describe("closestNames", () => { + test("returns an exact match", () => { + expect(closestNames("welcome", ["welcome", "other"])).toEqual(["welcome"]) + }) + + test("returns a typo match", () => { + expect(closestNames("welcom_promo", ["welcome_promo"])).toEqual([ + "welcome_promo", + ]) + }) + + test("returns a prefix match", () => { + expect(closestNames("wel", ["welcome_promo"])).toEqual(["welcome_promo"]) + }) + + test("omits unrelated names", () => { + expect(closestNames("welcome", ["archive", "assignment"])).toEqual([]) + }) + + test("caps results at three candidates", () => { + expect( + closestNames("alpha", ["alpha", "alpha1", "alpha2", "alpha3"]), + ).toEqual(["alpha", "alpha1", "alpha2"]) + }) +}) + +describe("zodErrorToFlowAuthoringErrors", () => { + test("uses a supplied path mapper", () => { + const errors = zodErrorToFlowAuthoringErrors( + getInvalidSpecError(), + "invalidStep", + (issuePath) => issuePath.join("/"), + ) + + expect(errors).toMatchObject([ + { code: "invalidStep", path: "items/0/name" }, + ]) + }) + + test("uses the issue path without a mapper", () => { + const errors = zodErrorToFlowAuthoringErrors( + getInvalidSpecError(), + "invalidSpec", + ) + + expect(errors).toMatchObject([ + { code: "invalidSpec", path: "items[0].name" }, + ]) + }) + + test("falls back to the issue path when the mapper has no mapping", () => { + const errors = zodErrorToFlowAuthoringErrors( + getInvalidSpecError(), + "invalidStep", + () => undefined, + ) + + expect(errors).toMatchObject([ + { code: "invalidStep", path: "items[0].name" }, + ]) + }) +}) + +describe("formatZodPathSegment", () => { + test("formats object keys and array indices", () => { + expect(formatZodPathSegment("steps", 0)).toBe("steps[0]") + expect(formatZodPathSegment("steps[0]", "templateName")).toBe( + "steps[0].templateName", + ) + }) +}) diff --git a/packages/flow-config/__tests__/authoring/layout.test.ts b/packages/flow-config/__tests__/authoring/layout.test.ts new file mode 100644 index 0000000000..29c97c9fd3 --- /dev/null +++ b/packages/flow-config/__tests__/authoring/layout.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest" +import { layoutNodes } from "../../src/authoring/layout" + +const expectFinitePosition = ( + position: { x: number; y: number } | undefined, +) => { + expect(position).toBeDefined() + expect(Number.isFinite(position?.x)).toBe(true) + expect(Number.isFinite(position?.y)).toBe(true) +} + +describe("layoutNodes", () => { + test("places an unreachable node in a fallback column", () => { + const positions = layoutNodes( + ["start", "reachable", "unreachable"], + [{ source: "start", target: "reachable" }], + "start", + ) + const reachablePosition = positions.get("reachable") + const unreachablePosition = positions.get("unreachable") + + expectFinitePosition(unreachablePosition) + expect(unreachablePosition?.x).toBeGreaterThan(reachablePosition?.x ?? 0) + }) + + test("terminates on cycles and positions every node", () => { + const nodeIds = ["start", "first", "second"] + const positions = layoutNodes( + nodeIds, + [ + { source: "start", target: "first" }, + { source: "first", target: "second" }, + { source: "second", target: "first" }, + ], + "start", + ) + + expect(positions).toHaveLength(nodeIds.length) + for (const nodeId of nodeIds) { + expectFinitePosition(positions.get(nodeId)) + } + }) +}) diff --git a/packages/flow-config/__tests__/authoring/spec-schema.test.ts b/packages/flow-config/__tests__/authoring/spec-schema.test.ts index afd59774ac..83ef8f48b3 100644 --- a/packages/flow-config/__tests__/authoring/spec-schema.test.ts +++ b/packages/flow-config/__tests__/authoring/spec-schema.test.ts @@ -1,5 +1,240 @@ import { describe, expect, test } from "vitest" -import { flowSpecStepTypes } from "../../src" +import { z } from "zod" +import { flowSpecSchema, flowSpecStepTypes } from "../../src" + +type JsonSchemaNode = { + description?: unknown + properties?: Record + items?: JsonSchemaNode + anyOf?: JsonSchemaNode[] + $defs?: Record +} + +const isJsonSchemaNode = (value: unknown): value is JsonSchemaNode => + typeof value === "object" && value !== null + +const expectInvalid = (value: unknown) => { + const result = flowSpecSchema.safeParse(value) + expect(result.success).toBe(false) + if (result.success) { + throw new Error("Expected flow spec validation to fail") + } + return result.error.issues +} + +const createFlowSpec = (steps: unknown) => ({ + formatVersion: 1, + name: "Customer follow-up", + steps, +}) + +const createThreeLevelNestedFlowSpec = (deepStep: unknown) => + createFlowSpec([ + { + type: "branch", + cases: [ + { + when: [ + { + field: "email", + operator: "equals", + value: "customer@example.com", + }, + ], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [ + { + type: "send", + text: "Would you like to continue?", + buttons: [ + { + text: "Continue", + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [ + { + type: "branch", + cases: [ + { + when: [ + { + field: "firstName", + operator: "isNotEmpty", + }, + ], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [deepStep], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + +const visitJsonSchema = (schema: JsonSchemaNode): void => { + for (const [propertyName, propertySchema] of Object.entries( + schema.properties ?? {}, + )) { + const { description } = propertySchema + expect( + typeof description, + `Property "${propertyName}" is missing a description`, + ).toBe("string") + if (typeof description === "string") { + expect(description.trim().length).toBeGreaterThan(0) + } + visitJsonSchema(propertySchema) + } + + if (schema.items && isJsonSchemaNode(schema.items)) { + visitJsonSchema(schema.items) + } + for (const option of schema.anyOf ?? []) { + visitJsonSchema(option) + } + for (const definition of Object.values(schema.$defs ?? {})) { + visitJsonSchema(definition) + } +} + +describe("flowSpecSchema", () => { + test("emits JSON Schema descriptions for every property, including recursive steps", () => { + const jsonSchema = z.toJSONSchema(flowSpecSchema) + expect(isJsonSchemaNode(jsonSchema)).toBe(true) + if (!isJsonSchemaNode(jsonSchema)) { + throw new Error( + "Expected flowSpecSchema to serialize to a JSON Schema object", + ) + } + + expect(typeof jsonSchema.description).toBe("string") + if (typeof jsonSchema.description !== "string") { + throw new Error("Expected flowSpecSchema to have a description") + } + expect(jsonSchema.description.trim()).not.toHaveLength(0) + visitJsonSchema(jsonSchema) + }) + + test("requires exactly one send content kind", () => { + const missingContentIssues = expectInvalid( + createFlowSpec([{ type: "send" }]), + ) + const multipleContentIssues = expectInvalid( + createFlowSpec([ + { + type: "send", + text: "Hello", + imageUrl: "https://example.com/image.png", + }, + ]), + ) + + for (const issues of [missingContentIssues, multipleContentIssues]) { + expect(issues).toContainEqual( + expect.objectContaining({ + message: "Exactly one of text, imageUrl, or fileUrl is required.", + path: ["steps", 0], + }), + ) + } + }) + + test("enforces action-specific required fields", () => { + const missingTagsIssues = expectInvalid( + createFlowSpec([{ type: "action", action: "addTags", tagNames: [] }]), + ) + const missingCustomFieldIssues = expectInvalid( + createFlowSpec([{ type: "action", action: "setCustomField" }]), + ) + + expect(missingTagsIssues).toContainEqual( + expect.objectContaining({ + message: 'action "addTags" requires a non-empty tagNames', + path: ["steps", 0, "tagNames"], + }), + ) + expect(missingCustomFieldIssues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'action "setCustomField" requires customFieldName', + path: ["steps", 0, "customFieldName"], + }), + expect.objectContaining({ + message: 'action "setCustomField" requires value', + path: ["steps", 0, "value"], + }), + ]), + ) + }) + + test("parses three nested branch/button levels and preserves a deep error path", () => { + expect( + flowSpecSchema.safeParse( + createThreeLevelNestedFlowSpec({ + type: "addNote", + note: "Internal note", + }), + ).success, + ).toBe(true) + + const issues = expectInvalid( + createThreeLevelNestedFlowSpec({ type: "addNote", note: "" }), + ) + + expect(issues).toContainEqual( + expect.objectContaining({ + path: [ + "steps", + 0, + "cases", + 0, + "then", + 0, + "buttons", + 0, + "then", + 0, + "cases", + 0, + "then", + 0, + "note", + ], + }), + ) + }) + + test("enforces flow spec limits", () => { + const tooManyButtons = Array.from({ length: 4 }, (_, index) => ({ + text: `Option ${index + 1}`, + })) + + for (const invalidSpec of [ + createFlowSpec([{ type: "send", text: "x".repeat(1001) }]), + createFlowSpec([ + { type: "send", text: "Hello", buttons: tooManyButtons }, + ]), + createFlowSpec([ + { + type: "send", + text: "Hello", + buttons: [{ text: "x".repeat(21) }], + }, + ]), + { + ...createFlowSpec([{ type: "send", text: "Hello" }]), + name: "x".repeat(256), + }, + ]) { + expect(flowSpecSchema.safeParse(invalidSpec).success).toBe(false) + } + }) +}) describe("flowSpecStepTypes", () => { test("derives one entry per flowStepSpecSchema member, each with a non-empty description", () => { diff --git a/packages/flow-config/src/authoring/compile.ts b/packages/flow-config/src/authoring/compile.ts index bb24b1c2ed..03de788cb9 100644 --- a/packages/flow-config/src/authoring/compile.ts +++ b/packages/flow-config/src/authoring/compile.ts @@ -1,7 +1,7 @@ import { createId } from "@chatbotx.io/utils" import { addNotesNodeDefaultFn } from "../nodes/add-notes" import { conditionNodeDefaultFn } from "../nodes/condition" -import type { EdgeSchema, FlowNode, FlowVersionSchema } from "../nodes/index" +import type { EdgeSchema, FlowVersionSchema } from "../nodes/index" import { performActionNodeDefaultFn } from "../nodes/perform-action" import { sendMessageNodeDefaultFn } from "../nodes/send-message" import { startFlowNodeDefaultFn } from "../nodes/start-flow" @@ -16,7 +16,10 @@ import { archiveConversationStepDefaultFn } from "../steps/archive-conversation" import { assignConversationStepDefaultFn } from "../steps/assign-conversation" import { type ButtonStepProps, buttonStepDefaultFn } from "../steps/button" import { chooseChannelStepDefaultFn } from "../steps/choose-channel" -import { conditionCaseDefaultFn } from "../steps/condition" +import { + conditionCaseDefaultFn, + conditionStepDefaultFn, +} from "../steps/condition" import { removeContactTagStepDefaultFn } from "../steps/remove-contact-tag" import { sendFileStepDefaultFn } from "../steps/send-file" import { sendImageStepDefaultFn } from "../steps/send-image" @@ -55,11 +58,17 @@ export type CompiledFlow = { specPathByNodeId: ReadonlyMap } -/** Step `type`s that end their step list — nothing may follow them. */ -const TERMINAL_STEP_TYPES: ReadonlySet = new Set([ - "branch", - "goto", -]) +/** Whether a step ends its step list — nothing may follow it. */ +const ENDS_STEP_LIST: Record = { + send: false, + sendTemplate: false, + wait: false, + branch: true, + action: false, + startFlow: false, + addNote: false, + goto: true, +} type CompileState = { nodes: FlowVersionSchema[] @@ -72,6 +81,39 @@ type CompileState = { channel?: string } +type ResolveByNameOptions = { + code: FlowAuthoringErrorCode + label: string + hint: string +} + +type ChildChain = { + steps: readonly FlowStepSpec[] + pathSuffix: string +} + +const TEMPLATE_NAME_RESOLUTION = { + code: "unknownTemplate", + label: "WhatsApp template", + hint: "Call capabilities.get and pick a name from its templates list.", +} as const satisfies ResolveByNameOptions + +const FLOW_NAME_RESOLUTION = { + code: "unknownFlow", + label: "flow", + hint: "Call flows.list and pick a name from the results.", +} as const satisfies ResolveByNameOptions + +const CUSTOM_FIELD_NAME_RESOLUTION = { + code: "unknownCustomField", + label: "custom field", + hint: "Call contacts.listFilterFields and pick a custom field name from the results.", +} as const satisfies ResolveByNameOptions + +// Matches the WhatsApp template status enum in +// packages/database/src/partials/integration-whatsapp.ts. +const APPROVED_TEMPLATE_STATUS = "APPROVED" + const addError = ( state: CompileState, path: string, @@ -82,52 +124,93 @@ const addError = ( state.errors.push({ path, code, message, ...extra }) } +function resolveByName( + state: CompileState, + map: ReadonlyMap, + name: string, + path: string, + { code, label, hint }: ResolveByNameOptions, +): T | null { + const value = map.get(name) + if (!value) { + addError( + state, + path, + code, + `No ${label} named "${name}" in this workspace.`, + { hint, candidates: closestNames(name, map.keys()) }, + ) + return null + } + return value +} + +/** + * Nested step lists owned by one step, with their path relative to that step. + * The compiler itself cannot consume this directly because it needs each + * child's handle id while it compiles the child chain. + */ +function childChains(step: FlowStepSpec): ChildChain[] { + switch (step.type) { + case "send": + return (step.buttons ?? []).flatMap((button, index) => + button.then + ? [{ steps: button.then, pathSuffix: `.buttons[${index}].then` }] + : [], + ) + case "branch": + return [ + ...step.cases.map((branchCase, index) => ({ + steps: branchCase.then, + pathSuffix: `.cases[${index}].then`, + })), + ...(step.otherwise + ? [{ steps: step.otherwise, pathSuffix: ".otherwise" }] + : []), + ] + case "sendTemplate": + case "wait": + case "action": + case "startFlow": + case "addNote": + case "goto": + return [] + default: { + const _exhaustive: never = step + return _exhaustive + } + } +} + /** Every explicit `id` a spec declares, recursively, for the pre-pass uniqueness check. */ -function collectExplicitStepIds( +function explicitStepIds( steps: readonly FlowStepSpec[], - seen: Map, pathPrefix: string, -): void { - steps.forEach((step, index) => { +): Array<{ id: string; path: string }> { + return steps.flatMap((step, index) => { const stepPath = `${pathPrefix}[${index}]` - if (step.type !== "goto" && step.id) { - const paths = seen.get(step.id) - if (paths) { - paths.push(stepPath) - } else { - seen.set(step.id, [stepPath]) - } - } - - if (step.type === "send") { - step.buttons?.forEach((button, buttonIndex) => { - if (button.then) { - collectExplicitStepIds( - button.then, - seen, - `${stepPath}.buttons[${buttonIndex}].then`, - ) - } - }) - } - if (step.type === "branch") { - step.cases.forEach((branchCase, caseIndex) => { - collectExplicitStepIds( - branchCase.then, - seen, - `${stepPath}.cases[${caseIndex}].then`, - ) - }) - if (step.otherwise) { - collectExplicitStepIds(step.otherwise, seen, `${stepPath}.otherwise`) - } - } + return [ + ...(step.type !== "goto" && step.id + ? [{ id: step.id, path: stepPath }] + : []), + ...childChains(step).flatMap(({ steps: childSteps, pathSuffix }) => + explicitStepIds(childSteps, `${stepPath}${pathSuffix}`), + ), + ] }) } function assertNoDuplicateStepIds(spec: FlowSpec, state: CompileState): void { const seen = new Map() - collectExplicitStepIds(spec.steps, seen, "steps") + for (const { id, path } of explicitStepIds(spec.steps, "steps")) { + const paths = seen.get(id) + if (paths) { + paths.push(path) + } else { + seen.set(id, [path]) + } + } + for (const [id, paths] of seen) { if (paths.length > 1) { for (const path of paths) { @@ -147,8 +230,13 @@ const registerNode = ( specStepId: string | undefined, node: FlowVersionSchema, stepPath: string, + options?: { insertAt?: number }, ): string => { - state.nodes.push(node) + if (options?.insertAt === undefined) { + state.nodes.push(node) + } else { + state.nodes.splice(options.insertAt, 0, node) + } state.specPathByNodeId.set(node.id, stepPath) if (specStepId) { state.stepIdToNodeId.set(specStepId, node.id) @@ -156,34 +244,42 @@ const registerNode = ( return node.id } -const addContinueEdge = ( +const addHandleEdge = ( state: CompileState, source: string, + handleId: string, target: string, ): void => { state.edges.push({ id: createId(), source, - sourceHandle: source, + sourceHandle: handleId, target, targetHandle: target, }) } -const addHandleEdge = ( +const addContinueEdge = ( state: CompileState, source: string, - handleId: string, target: string, -): void => { - state.edges.push({ - id: createId(), - source, - sourceHandle: handleId, - target, - targetHandle: target, - }) -} +): void => addHandleEdge(state, source, source, target) + +type NodeWithSteps = Extract< + FlowVersionSchema, + { data: { details: { steps: unknown[] } } } +> + +const withSteps = ( + node: T, + steps: T["data"]["details"]["steps"], +): T => ({ + ...node, + data: { + ...node.data, + details: { ...node.data.details, steps }, + }, +}) // ---- Per-step-type node builders ----------------------------------------- @@ -216,19 +312,8 @@ function compileSendStep( stepPath: string, state: CompileState, ): string { - const node = sendMessageNodeDefaultFn({ - detailProps: { - beforeStep: chooseChannelStepDefaultFn({ - channel: state.channel ?? "omnichannel", - }), - }, - }) - // Registered before compiling nested button chains so `state.nodes` keeps - // encounter order (this node, then whatever its buttons route to) instead - // of the reverse — `node` is a reference, so mutating its `data.details` - // below still updates the array element already pushed. - const nodeId = registerNode(state, step.id, node, stepPath) - + const nodeId = createId() + const insertAt = state.nodes.length const buttons = (step.buttons ?? []).map((buttonSpec, buttonIndex) => compileSendButton( buttonSpec, @@ -237,7 +322,6 @@ function compileSendStep( state, ), ) - const contentStep = (() => { if (step.text) { return { ...sendTextStepDefaultFn({ text: step.text }), buttons } @@ -247,11 +331,19 @@ function compileSendStep( } return { ...sendFileStepDefaultFn(), url: step.fileUrl ?? "", buttons } })() + const node = withSteps( + sendMessageNodeDefaultFn({ + nodeProps: { id: nodeId }, + detailProps: { + beforeStep: chooseChannelStepDefaultFn({ + channel: state.channel ?? "omnichannel", + }), + }, + }), + [contentStep], + ) - node.data.details.steps = [contentStep] - node.data.details.quickReplies = [] - - return nodeId + return registerNode(state, step.id, node, stepPath, { insertAt }) } function compileSendTemplateStep( @@ -259,19 +351,24 @@ function compileSendTemplateStep( stepPath: string, state: CompileState, ): string | null { - const template = state.ctx.templatesByName.get(step.templateName) + const template = resolveByName( + state, + state.ctx.templatesByName, + step.templateName, + `${stepPath}.templateName`, + TEMPLATE_NAME_RESOLUTION, + ) if (!template) { + return null + } + if (template.status !== APPROVED_TEMPLATE_STATUS) { addError( state, `${stepPath}.templateName`, - "unknownTemplate", - `No WhatsApp template named "${step.templateName}" in this workspace.`, + "templateNotApproved", + `WhatsApp template "${step.templateName}" must have status APPROVED.`, { - hint: "Call capabilities.get and pick a name from its templates list.", - candidates: closestNames( - step.templateName, - state.ctx.templatesByName.keys(), - ), + hint: "Pick a template whose status is APPROVED in capabilities.get's templates list.", }, ) return null @@ -285,15 +382,16 @@ function compileSendTemplateStep( params: {}, }, }) - - const node = sendMessageNodeDefaultFn({ - detailProps: { - // A WA template step only ever sends over WhatsApp — pin the channel - // regardless of the flow's own default channel. - beforeStep: chooseChannelStepDefaultFn({ channel: "whatsapp" }), - }, - }) - node.data.details.steps = [templateStep] + const node = withSteps( + sendMessageNodeDefaultFn({ + detailProps: { + // A WA template step only ever sends over WhatsApp — pin the channel + // regardless of the flow's own default channel. + beforeStep: chooseChannelStepDefaultFn({ channel: "whatsapp" }), + }, + }), + [templateStep], + ) return registerNode(state, step.id, node, stepPath) } @@ -308,9 +406,12 @@ function compileWaitStep( duration: step.duration, unit: step.unit, } - const node = waitNodeDefaultFn({}) - node.data.details.steps = [waitStep] - return registerNode(state, step.id, node, stepPath) + return registerNode( + state, + step.id, + withSteps(waitNodeDefaultFn({}), [waitStep]), + stepPath, + ) } function compileActionStep( @@ -353,9 +454,12 @@ function compileActionStep( } })() - const node = performActionNodeDefaultFn({}) - node.data.details.steps = [actionStep] - return registerNode(state, step.id, node, stepPath) + return registerNode( + state, + step.id, + withSteps(performActionNodeDefaultFn({}), [actionStep]), + stepPath, + ) } function compileStartFlowStep( @@ -363,18 +467,14 @@ function compileStartFlowStep( stepPath: string, state: CompileState, ): string | null { - const targetFlow = state.ctx.flowsByName.get(step.flowName) + const targetFlow = resolveByName( + state, + state.ctx.flowsByName, + step.flowName, + `${stepPath}.flowName`, + FLOW_NAME_RESOLUTION, + ) if (!targetFlow) { - addError( - state, - `${stepPath}.flowName`, - "unknownFlow", - `No flow named "${step.flowName}" in this workspace.`, - { - hint: "Call flows.list and pick a name from the results.", - candidates: closestNames(step.flowName, state.ctx.flowsByName.keys()), - }, - ) return null } @@ -419,21 +519,13 @@ function resolveCustomField( path: string, state: CompileState, ): { id: string; type: string } | null { - const customField = state.ctx.customFieldsByName.get(name) - if (!customField) { - addError( - state, - path, - "unknownCustomField", - `No custom field named "${name}" in this workspace.`, - { - hint: "Call contacts.listFilterFields and pick a custom field name from the results.", - candidates: closestNames(name, state.ctx.customFieldsByName.keys()), - }, - ) - return null - } - return customField + return resolveByName( + state, + state.ctx.customFieldsByName, + name, + path, + CUSTOM_FIELD_NAME_RESOLUTION, + ) } function resolveBranchCondition( @@ -477,16 +569,10 @@ function compileBranchStep( stepPath: string, state: CompileState, ): string { - const node = conditionNodeDefaultFn({}) - const conditionStep = node.data.details.steps[0] - if (!conditionStep) { - throw new Error("conditionNodeDefaultFn produced no condition step") - } - // Registered before compiling case/otherwise chains — see the identical - // note on `compileSendStep`. - const nodeId = registerNode(state, step.id, node, stepPath) - - conditionStep.cases = step.cases.map((branchCase, caseIndex) => { + const nodeId = createId() + const insertAt = state.nodes.length + const otherwiseId = createId() + const cases = step.cases.map((branchCase, caseIndex) => { const caseDefault = conditionCaseDefaultFn() const casePath = `${stepPath}.cases[${caseIndex}]` const conditions = branchCase.when @@ -518,11 +604,15 @@ function compileBranchStep( state, ) if (entryNodeId) { - addHandleEdge(state, nodeId, conditionStep.otherwiseId, entryNodeId) + addHandleEdge(state, nodeId, otherwiseId, entryNodeId) } } - return nodeId + const node = withSteps( + conditionNodeDefaultFn({ nodeProps: { id: nodeId } }), + [conditionStepDefaultFn({ cases, otherwiseId })], + ) + return registerNode(state, step.id, node, stepPath, { insertAt }) } function compileGotoStep( @@ -593,7 +683,7 @@ function compileChain( steps.forEach((step, index) => { const stepPath = `${pathPrefix}[${index}]` - const isTerminal = TERMINAL_STEP_TYPES.has(step.type) + const isTerminal = ENDS_STEP_LIST[step.type] if (index < steps.length - 1 && isTerminal) { addError( @@ -639,39 +729,21 @@ function withLayoutPosition( return { ...node, position, data: { ...node.data, isStartNode } } } -/** - * Compiles a `flowSpecSchema`-shaped spec into `{ startNodeId, nodes, edges }` - * ready for `publishFlowSchema.parse` / `flowVersionService.publish`. - * - * Every node comes from its canonical `*NodeDefaultFn` so it can never drift - * from the builder's own defaults (position/measured are overwritten by - * `layoutNodes` afterward; everything else — `data`, default sub-steps — is - * exactly what the builder itself would create). Button routing is never - * hand-assembled: routes are collected as `FlowRouteUpdate`s and applied in - * one call to `applyRouteUpdatesInNodes`, the same helper the builder UI - * uses, so a future change to how routes are stored is picked up here for - * free. - * - * Throws `FlowAuthoringException` (never a partial result) when compilation - * hits any error — reference-name lookups, duplicate ids, or structural - * issues (an unreachable step, a `goto` to an unknown id). Every error found - * is collected before throwing, not just the first. - */ -export function compileFlowSpec( +const createCompileState = ( spec: FlowSpec, ctx: FlowAuthoringContext, -): CompiledFlow { - const state: CompileState = { - nodes: [], - edges: [], - routeUpdates: [], - stepIdToNodeId: new Map(), - specPathByNodeId: new Map(), - errors: [], - ctx, - channel: spec.channel, - } - +): CompileState => ({ + nodes: [], + edges: [], + routeUpdates: [], + stepIdToNodeId: new Map(), + specPathByNodeId: new Map(), + errors: [], + ctx, + channel: spec.channel, +}) + +const validateStructure = (spec: FlowSpec, state: CompileState): void => { assertNoDuplicateStepIds(spec, state) if (spec.steps[0]?.type === "goto") { @@ -682,13 +754,14 @@ export function compileFlowSpec( '"goto" cannot be the first step — there is no earlier step yet to jump from.', ) } +} - const startNodeId = compileChain(spec.steps, "steps", state) - - if (state.errors.length > 0) { - throw new FlowAuthoringException(state.errors) - } - +const finalizeGraph = ( + state: CompileState, + startNodeId: string | null, +): CompiledFlow => { + // `flowSpecSchema` requires steps, but a TypeScript caller can bypass parsing + // and pass `steps: []`; the finalized graph must still have a start node. if (!startNodeId) { throw new FlowAuthoringException([ { @@ -699,23 +772,12 @@ export function compileFlowSpec( ]) } - // `state.nodes` are `FlowVersionSchema` (this package's compiler-output - // type); `applyRouteUpdatesInNodes` operates on reactflow's generic - // `FlowNode = Node`, a structurally different - // shape (position/measured/type are generic there, not the discriminated - // union). The double cast crosses that boundary; node `id`s — what - // `specPathByNodeId` keys on below — are preserved through it either way. - const routedNodes = applyRouteUpdatesInNodes( - state.nodes as unknown as FlowNode[], - state.routeUpdates, - ) as unknown as FlowVersionSchema[] - + const routedNodes = applyRouteUpdatesInNodes(state.nodes, state.routeUpdates) const positions = layoutNodes( routedNodes.map((node) => node.id), state.edges, startNodeId, ) - const nodes = routedNodes.map((node, index) => withLayoutPosition( node, @@ -731,3 +793,35 @@ export function compileFlowSpec( specPathByNodeId: state.specPathByNodeId, } } + +/** + * Compiles a `flowSpecSchema`-shaped spec into `{ startNodeId, nodes, edges }` + * ready for `publishFlowSchema.parse` / `flowVersionService.publish`. + * + * Every node comes from its canonical `*NodeDefaultFn` so it can never drift + * from the builder's own defaults (only `position` is overwritten by + * `layoutNodes`; `measured`, `data`, and default sub-steps are exactly what + * the builder itself would create). Button routing is never hand-assembled: + * routes are collected as `FlowRouteUpdate`s and applied in one call to + * `applyRouteUpdatesInNodes`, the same helper the builder UI uses, so a + * future change to how routes are stored is picked up here for free. + * + * Throws `FlowAuthoringException` (never a partial result) when compilation + * hits any error — reference-name lookups, duplicate ids, or structural + * issues (an unreachable step, a `goto` to an unknown id). Every error found + * is collected before throwing, not just the first. + */ +export function compileFlowSpec( + spec: FlowSpec, + ctx: FlowAuthoringContext, +): CompiledFlow { + const state = createCompileState(spec, ctx) + validateStructure(spec, state) + const startNodeId = compileChain(spec.steps, "steps", state) + + if (state.errors.length > 0) { + throw new FlowAuthoringException(state.errors) + } + + return finalizeGraph(state, startNodeId) +} diff --git a/packages/flow-config/src/authoring/errors.ts b/packages/flow-config/src/authoring/errors.ts index 8ec3af06f5..d5b3c50e7e 100644 --- a/packages/flow-config/src/authoring/errors.ts +++ b/packages/flow-config/src/authoring/errors.ts @@ -20,6 +20,7 @@ export type FlowAuthoringErrorCode = | "duplicateStepId" | "invalidStep" | "compileFailed" + | "templateNotApproved" export type FlowAuthoringError = { /** Spec-relative path, e.g. `steps[2].templateName` — never a compiled-node path. */ @@ -56,16 +57,16 @@ export const formatZodPathSegment = ( } /** - * Converts a `flowSpecSchema`/`publishFlowSchema` parse failure into - * `FlowAuthoringError[]`. Without `mapPath`, issue paths are used verbatim — - * correct when validating the spec itself, where paths are already - * spec-relative. `compileAndValidateSpec` (`apps/builder`) passes `mapPath` - * when validating the *compiled* node graph instead, to translate a - * node-graph path back to the spec-relative path an agent actually wrote. + * Converts a parse failure into `FlowAuthoringError[]` using the caller's + * diagnostic code. Without `mapPath`, issue paths are used verbatim — correct + * when validating the spec itself, where paths are already spec-relative. + * `compileAndValidateSpec` (`apps/builder`) passes `mapPath` when validating + * the *compiled* node graph instead, to translate a node-graph path back to + * the spec-relative path an agent actually wrote. */ export function zodErrorToFlowAuthoringErrors( error: z.ZodError, - code: FlowAuthoringErrorCode = "invalidSpec", + code: FlowAuthoringErrorCode, mapPath?: (issuePath: PropertyKey[]) => string | undefined, ): FlowAuthoringError[] { return error.issues.map((issue) => ({ @@ -111,22 +112,27 @@ function levenshteinDistance(a: string, b: string): number { // match, or an edit distance small relative to name length (typo-tolerant // without matching two genuinely unrelated short names). const MAX_EDIT_DISTANCE_RATIO = 0.34 +const PREFIX_MATCH_BASE_SCORE = 1000 +const SUBSTRING_MATCH_BASE_SCORE = 500 +const MIN_ALLOWED_EDIT_DISTANCE = 2 function nameSimilarity(target: string, candidate: string): number { if (target === candidate) { return Number.POSITIVE_INFINITY } if (candidate.startsWith(target) || target.startsWith(candidate)) { - return 1000 - Math.abs(candidate.length - target.length) + return PREFIX_MATCH_BASE_SCORE - Math.abs(candidate.length - target.length) } if (candidate.includes(target) || target.includes(candidate)) { - return 500 - Math.abs(candidate.length - target.length) + return ( + SUBSTRING_MATCH_BASE_SCORE - Math.abs(candidate.length - target.length) + ) } const distance = levenshteinDistance(target, candidate) const maxLength = Math.max(target.length, candidate.length) const allowedDistance = Math.max( - 2, + MIN_ALLOWED_EDIT_DISTANCE, Math.ceil(maxLength * MAX_EDIT_DISTANCE_RATIO), ) return distance <= allowedDistance ? maxLength - distance : 0 diff --git a/packages/flow-config/src/authoring/layout.ts b/packages/flow-config/src/authoring/layout.ts index 8589d60e65..9f8e4ef5bf 100644 --- a/packages/flow-config/src/authoring/layout.ts +++ b/packages/flow-config/src/authoring/layout.ts @@ -1,10 +1,10 @@ +import { DEFAULT_NODE_MEASURED } from "../nodes/base" import type { EdgeSchema } from "../nodes/index" -// Node width/height mirror `defaultNodeData()`'s `measured` in `../nodes/base.ts` -// — every node type shares the same default footprint, so layout can use one -// fixed cell size instead of asking each node for its own. -const NODE_WIDTH = 288 -const NODE_HEIGHT = 100 +// Every node type uses the same default footprint, so layout can use one fixed +// cell size instead of asking each node for its own. +const NODE_WIDTH = DEFAULT_NODE_MEASURED.width +const NODE_HEIGHT = DEFAULT_NODE_MEASURED.height const COLUMN_GAP = 120 const ROW_GAP = 80 const COLUMN_WIDTH = NODE_WIDTH + COLUMN_GAP diff --git a/packages/flow-config/src/authoring/spec-schema.ts b/packages/flow-config/src/authoring/spec-schema.ts index ed2eabf8d8..d2ec8f1ab2 100644 --- a/packages/flow-config/src/authoring/spec-schema.ts +++ b/packages/flow-config/src/authoring/spec-schema.ts @@ -1,7 +1,12 @@ import { channelTypes } from "@chatbotx.io/utils/channel" import { z } from "zod" +import { BUTTON_LABEL_MAX } from "../steps/button" import { waitStepDelayUnits } from "../steps/wait" +const MAX_SPEC_BUTTONS = 3 +const MAX_SPEC_TEXT_LENGTH = 1000 +const MAX_FLOW_NAME_LENGTH = 255 + /** * The agent-facing flow DSL. A small, deliberately curated subset of the * full node/step surface — the goal is an agent reliably building a working @@ -21,7 +26,7 @@ export type FlowStepSpec = text?: string imageUrl?: string fileUrl?: string - buttons?: Array<{ id?: string; text: string; then?: FlowStepSpec[] }> + buttons?: Array<{ text: string; then?: FlowStepSpec[] }> } | { type: "sendTemplate"; id?: string; templateName: string } | { @@ -34,7 +39,6 @@ export type FlowStepSpec = type: "branch" id?: string cases: Array<{ - id?: string match?: "and" | "or" when: Array<{ field: string @@ -71,44 +75,49 @@ const stepIdField = z "Stable id for this step. Omit to auto-generate one. Set it explicitly when a `goto` elsewhere needs to jump to this exact step.", ) +const stepTypeLiteral = (type: Type) => + z.literal(type).describe(`Step type: "${type}".`) + const sendButtonSpecSchema: z.ZodType<{ - id?: string text: string then?: FlowStepSpec[] }> = z.lazy(() => - z.object({ - id: z.string().min(1).optional(), - text: z - .string() - .trim() - .min(1) - .max(20) - .describe("Button label shown to the contact (max 20 characters)."), - // DSL vocabulary ("steps to run then"), not an accidental thenable — the - // value is an array, never callable, so nothing ever treats this object - // as a Promise. - // biome-ignore lint/suspicious/noThenProperty: see comment above - then: z - .array(flowStepSpecSchema) - .optional() - .describe( - "Steps to run when the contact taps this button. Omitted or empty means the button has no follow-up.", - ), - }), + z + .object({ + text: z + .string() + .trim() + .min(1) + .max(BUTTON_LABEL_MAX) + .describe( + `Button label shown to the contact (max ${BUTTON_LABEL_MAX} characters).`, + ), + // DSL vocabulary ("steps to run then"), not an accidental thenable — the + // value is an array, never callable, so nothing ever treats this object + // as a Promise. + // biome-ignore lint/suspicious/noThenProperty: see comment above + then: z + .array(flowStepSpecSchema) + .optional() + .describe( + "Steps to run when the contact taps this button. Omitted or empty means the button has no follow-up.", + ), + }) + .describe("A quick-reply button attached to a send step."), ) const sendStepSpecSchema = z .object({ - type: z.literal("send"), + type: stepTypeLiteral("send"), id: stepIdField, text: z .string() .trim() .min(1) - .max(1000) + .max(MAX_SPEC_TEXT_LENGTH) .optional() .describe( - "Text message body. Exactly one of text/imageUrl/fileUrl is required.", + `Text message body (max ${MAX_SPEC_TEXT_LENGTH} characters). Exactly one of text/imageUrl/fileUrl is required.`, ), imageUrl: z .url() @@ -124,13 +133,12 @@ const sendStepSpecSchema = z ), buttons: z .array(sendButtonSpecSchema) - .max(3) + .max(MAX_SPEC_BUTTONS) .optional() - .describe("Up to 3 quick-reply buttons attached to this message."), + .describe( + `Up to ${MAX_SPEC_BUTTONS} quick-reply buttons attached to this message.`, + ), }) - .describe( - "Sends one message (text, image, or file), optionally with quick-reply buttons.", - ) .superRefine((data, ctx) => { const kinds = [data.text, data.imageUrl, data.fileUrl].filter( (value) => value !== undefined, @@ -143,24 +151,27 @@ const sendStepSpecSchema = z }) } }) + .describe( + "Sends one message (text, image, or file), optionally with quick-reply buttons.", + ) const sendTemplateStepSpecSchema = z .object({ - type: z.literal("sendTemplate"), + type: stepTypeLiteral("sendTemplate"), id: stepIdField, templateName: z .string() .trim() .min(1) .describe( - "Name of an existing, approved WhatsApp message template (see `capabilities.get`'s `templates` list). Sent as-is, without dynamic variables.", + "Name of an existing WhatsApp message template whose status is `APPROVED` (see `capabilities.get`'s `templates` list). Sent as-is, without dynamic variables.", ), }) .describe("Sends an existing WhatsApp message template.") const waitStepSpecSchema = z .object({ - type: z.literal("wait"), + type: stepTypeLiteral("wait"), id: stepIdField, duration: z .number() @@ -171,29 +182,34 @@ const waitStepSpecSchema = z }) .describe("Pauses the flow for a fixed duration before continuing.") -const branchConditionSpecSchema = z.object({ - field: z - .string() - .min(1) - .describe( - "A static field name from `GET /v1/contacts/filter-fields`, or `customField:` to reference a workspace custom field by name (resolved automatically — use the exact name from `contacts.listFilterFields`). `botField:` is not yet supported.", - ), - operator: z - .string() - .min(1) - .describe( - "One of the operators `GET /v1/contacts/filter-fields` lists for this field.", - ), - value: z - .union([z.string(), z.array(z.string()), z.tuple([z.string(), z.string()])]) - .optional() - .describe( - "Comparison value. Omit for valueless operators (e.g. isEmpty/isNotEmpty). A two-element tuple is a between-range.", - ), -}) +const branchConditionSpecSchema = z + .object({ + field: z + .string() + .min(1) + .describe( + "A static field name from `GET /v1/contacts/filter-fields`, or `customField:` to reference a workspace custom field by name (resolved automatically — use the exact name from `contacts.listFilterFields`). `botField:` prefixed names are rejected by the compiler with error code `invalidSpec`; bot fields are reference-only data.", + ), + operator: z + .string() + .min(1) + .describe( + "One of the operators `GET /v1/contacts/filter-fields` lists for this field.", + ), + value: z + .union([ + z.string(), + z.array(z.string()), + z.tuple([z.string(), z.string()]), + ]) + .optional() + .describe( + "Comparison value. Omit for valueless operators (e.g. isEmpty/isNotEmpty). A two-element tuple is a between-range.", + ), + }) + .describe("One contact-filter-style condition for a branch case.") const branchCaseSpecSchema: z.ZodType<{ - id?: string match?: "and" | "or" when: Array<{ field: string @@ -202,30 +218,39 @@ const branchCaseSpecSchema: z.ZodType<{ }> then: FlowStepSpec[] }> = z.lazy(() => - z.object({ - id: z.string().min(1).optional(), - match: z - .enum(["and", "or"]) - .default("and") - .describe( - "Whether every ('and') or any ('or') condition in `when` must match.", - ), - when: z.array(branchConditionSpecSchema).min(1), - // DSL vocabulary, see the identical note on the button step's `then` - // above. - // biome-ignore lint/suspicious/noThenProperty: see comment above - then: z - .array(flowStepSpecSchema) - .min(1) - .describe("Steps to run when this case matches."), - }), + z + .object({ + match: z + .enum(["and", "or"]) + .default("and") + .describe( + "Whether every ('and') or any ('or') condition in `when` must match.", + ), + when: z + .array(branchConditionSpecSchema) + .min(1) + .describe( + "Conditions evaluated to determine whether this case matches.", + ), + // DSL vocabulary, see the identical note on the button step's `then` + // above. + // biome-ignore lint/suspicious/noThenProperty: see comment above + then: z + .array(flowStepSpecSchema) + .min(1) + .describe("Steps to run when this case matches."), + }) + .describe("One ordered case in a branch step."), ) const branchStepSpecSchema = z .object({ - type: z.literal("branch"), + type: stepTypeLiteral("branch"), id: stepIdField, - cases: z.array(branchCaseSpecSchema).min(1), + cases: z + .array(branchCaseSpecSchema) + .min(1) + .describe("Ordered cases evaluated until one matches."), otherwise: z .array(z.lazy(() => flowStepSpecSchema)) .optional() @@ -237,7 +262,7 @@ const branchStepSpecSchema = z const actionStepSpecSchema = z .object({ - type: z.literal("action"), + type: stepTypeLiteral("action"), id: stepIdField, action: z .enum([ @@ -269,9 +294,6 @@ const actionStepSpecSchema = z "Workspace member id to assign the conversation to. Optional for `assignConversation`; omit to unassign.", ), }) - .describe( - "Performs a workspace side-effect: tag, custom field, or conversation action.", - ) .superRefine((data, ctx) => { if ( (data.action === "addTags" || data.action === "removeTags") && @@ -300,10 +322,13 @@ const actionStepSpecSchema = z } } }) + .describe( + "Performs a workspace side-effect: tag, custom field, or conversation action.", + ) const startFlowStepSpecSchema = z .object({ - type: z.literal("startFlow"), + type: stepTypeLiteral("startFlow"), id: stepIdField, flowName: z .string() @@ -319,20 +344,22 @@ const startFlowStepSpecSchema = z const addNoteStepSpecSchema = z .object({ - type: z.literal("addNote"), + type: stepTypeLiteral("addNote"), id: stepIdField, note: z .string() .trim() .min(1) - .max(1000) - .describe("Internal note text — never shown to the contact."), + .max(MAX_SPEC_TEXT_LENGTH) + .describe( + `Internal note text (max ${MAX_SPEC_TEXT_LENGTH} characters) — never shown to the contact.`, + ), }) .describe("Adds an internal note to the conversation.") const gotoStepSpecSchema = z .object({ - type: z.literal("goto"), + type: stepTypeLiteral("goto"), targetId: z .string() .min(1) @@ -344,7 +371,7 @@ const gotoStepSpecSchema = z "Terminal — routes to an already-defined step instead of continuing. Must be the last step in its list.", ) -export const flowStepSpecOptions = [ +const flowStepSpecOptions = [ sendStepSpecSchema, sendTemplateStepSpecSchema, waitStepSpecSchema, @@ -355,7 +382,7 @@ export const flowStepSpecOptions = [ gotoStepSpecSchema, ] as const -export const flowStepSpecSchema: z.ZodType = z.discriminatedUnion( +const flowStepSpecSchema: z.ZodType = z.discriminatedUnion( "type", flowStepSpecOptions, ) @@ -374,15 +401,24 @@ export const flowSpecStepTypes: FlowSpecStepType[] = flowStepSpecOptions.map( }), ) -export const flowSpecSchema = z.object({ - formatVersion: z.literal(1).describe("DSL format version. Always 1."), - name: z.string().trim().min(1).max(255).describe("Flow name."), - channel: channelTypes - .optional() - .describe("Channel this flow targets. Omit for any/omnichannel."), - steps: z - .array(flowStepSpecSchema) - .min(1) - .describe("Ordered steps executed from the flow's start node."), -}) +export const flowSpecSchema = z + .object({ + formatVersion: z.literal(1).describe("DSL format version. Always 1."), + name: z + .string() + .trim() + .min(1) + .max(MAX_FLOW_NAME_LENGTH) + .describe(`Flow name (max ${MAX_FLOW_NAME_LENGTH} characters).`), + channel: channelTypes + .optional() + .describe( + "Channel this flow targets. Omit for any/omnichannel. sendTemplate steps always send over WhatsApp regardless of this value.", + ), + steps: z + .array(flowStepSpecSchema) + .min(1) + .describe("Ordered steps executed from the flow's start node."), + }) + .describe("A complete agent-authored flow DSL document.") export type FlowSpec = z.infer diff --git a/packages/flow-config/src/index.ts b/packages/flow-config/src/index.ts index c4356d03a3..4370537011 100644 --- a/packages/flow-config/src/index.ts +++ b/packages/flow-config/src/index.ts @@ -1,7 +1,23 @@ // Export all definitions -export * from "./authoring/compile" -export * from "./authoring/errors" -export * from "./authoring/spec-schema" +export { + type CompiledFlow, + compileFlowSpec, + type FlowAuthoringContext, +} from "./authoring/compile" +export { + type FlowAuthoringError, + type FlowAuthoringErrorCode, + FlowAuthoringException, + formatZodPathSegment, + zodErrorToFlowAuthoringErrors, +} from "./authoring/errors" +export { + type FlowSpec, + type FlowSpecStepType, + type FlowStepSpec, + flowSpecSchema, + flowSpecStepTypes, +} from "./authoring/spec-schema" export * from "./channel-rules/channel-step-refinement" export * from "./channel-rules/channel-validator" export * from "./channel-rules/media-step-rules" diff --git a/packages/flow-config/src/nodes/base.ts b/packages/flow-config/src/nodes/base.ts index d2e58f069c..a051508327 100644 --- a/packages/flow-config/src/nodes/base.ts +++ b/packages/flow-config/src/nodes/base.ts @@ -48,8 +48,10 @@ export type DefaultNodeProps = { detailProps?: Partial<{ beforeStep: any }> } +export const DEFAULT_NODE_MEASURED = { width: 288, height: 100 } as const + export const defaultNodeData = () => ({ id: createId(), position: { x: 100, y: 300 }, - measured: { width: 288, height: 100 }, + measured: DEFAULT_NODE_MEASURED, }) diff --git a/packages/flow-config/src/routable-handle.ts b/packages/flow-config/src/routable-handle.ts index e2fcfc221a..317f8607af 100644 --- a/packages/flow-config/src/routable-handle.ts +++ b/packages/flow-config/src/routable-handle.ts @@ -375,10 +375,10 @@ export const applyRouteInNode = ( return firstUpdate?.node ?? null } -export const applyRouteUpdatesInNodes = ( - nodes: FlowNode[], +export const applyRouteUpdatesInNodes = ( + nodes: readonly N[], updates: readonly FlowRouteUpdate[], -): FlowNode[] => { +): N[] => { const updatesBySourceNode = new Map() for (const update of updates) { const sourceUpdates = updatesBySourceNode.get(update.sourceNodeId) @@ -390,13 +390,13 @@ export const applyRouteUpdatesInNodes = ( } let hasChanges = false - const updatedNodes = nodes.map((node) => { + const updatedNodes = nodes.map((node): N => { const sourceUpdates = updatesBySourceNode.get(node.id) if (!sourceUpdates) { return node } - let updatedNode = node + let updatedNode: FlowNode = node for (const update of sourceUpdates) { const nextNode = applyRouteInNode( updatedNode, @@ -409,8 +409,9 @@ export const applyRouteUpdatesInNodes = ( } } - return updatedNode + // Route replacement changes only data nested in the same concrete node. + return updatedNode as N }) - return hasChanges ? updatedNodes : nodes + return hasChanges ? updatedNodes : (nodes as N[]) } From c61387740a758dcdb559967d8942b0153b77fde6 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 09:39:32 +0700 Subject: [PATCH 05/38] refactor(capabilities): derive workspace capability schema from zod Adds packages/business/src/capabilities/schema.ts with a fully-described zod schema per entity (inbox/template/field/namedEntity/flowSpec/response), replacing the hand-maintained response types in service.ts and the duplicated response schema in the builder's public capabilities route. DEFAULT_INCLUDES is now derived from CAPABILITIES_INCLUDES minus an explicit opt-in list instead of hand-copied. Fixes the capabilities.get and schemas.flowSpec route descriptions (bot fields are reference-only; flows.publish/updateDraft/validate accept spec, not flows.create). --- .../src/features/capabilities/api/public.ts | 46 +------- .../__tests__/capabilities-service.test.ts | 55 ++++++++- packages/business/package.json | 1 + packages/business/src/capabilities/index.ts | 1 + packages/business/src/capabilities/schema.ts | 109 ++++++++++++++++++ packages/business/src/capabilities/service.ts | 72 +++++------- 6 files changed, 196 insertions(+), 88 deletions(-) create mode 100644 packages/business/src/capabilities/schema.ts diff --git a/apps/builder/src/features/capabilities/api/public.ts b/apps/builder/src/features/capabilities/api/public.ts index 79b912aad9..c494788f72 100644 --- a/apps/builder/src/features/capabilities/api/public.ts +++ b/apps/builder/src/features/capabilities/api/public.ts @@ -2,6 +2,7 @@ import { CAPABILITIES_INCLUDES, getCapabilities, } from "@chatbotx.io/business/capabilities" +import { capabilitiesResponseSchema } from "@chatbotx.io/business/capabilities/schema" import { flowSpecSchema } from "@chatbotx.io/flow-config" import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4" import { z } from "zod" @@ -18,45 +19,6 @@ import { workspaceTokenAuthAPIForScope } from "@/orpc" // disappearing without a trace. const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") -const namedEntityResponse = z.object({ id: z.string(), name: z.string() }) -const fieldResponse = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), -}) - -const capabilitiesPublicResponse = z.object({ - inboxes: z - .array(z.object({ id: z.string(), name: z.string(), channel: z.string() })) - .optional(), - templates: z - .array( - z.object({ - id: z.string(), - name: z.string(), - language: z.string(), - status: z.string(), - params: z.unknown(), - }), - ) - .optional(), - customFields: z.array(fieldResponse).optional(), - botFields: z.array(fieldResponse).optional(), - tags: z.array(namedEntityResponse).optional(), - aiAgents: z.array(namedEntityResponse).optional(), - sequences: z.array(namedEntityResponse).optional(), - flows: z.array(namedEntityResponse).optional(), - flowSpec: z - .object({ - stepTypes: z.array( - z.object({ type: z.string(), description: z.string() }), - ), - waitUnits: z.array(z.string()), - channels: z.array(z.string()), - }) - .optional(), -}) - const includeQueryParam = z.preprocess((value) => { if (typeof value !== "string") { return value @@ -84,12 +46,12 @@ export const capabilitiesPublicRouter = { summary: "Discover the workspace's inboxes, templates, fields, tags, sequences, and flows", description: - "Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.create`/`flows.publish` so names in a flow spec resolve to real ids instead of guesses.", + "Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Bot fields are reference data only; they cannot be used as a branch condition's `field` (only custom fields and built-in contact fields can). Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.publish`/`flows.updateDraft`/`flows.validate` so names in a flow spec resolve to real ids instead of guesses.", tags: ["Capabilities"], spec: mcpSpec({ visibility: "default", alwaysVisible: true }), }) .input(z.object({ include: includeQueryParam })) - .output(capabilitiesPublicResponse) + .output(capabilitiesResponseSchema) .errors(possibleErrorsOnListingResource) .handler( async ({ context, input }) => @@ -107,7 +69,7 @@ export const schemasPublicRouter = { path: "/v1/schemas/flow-spec", summary: "Get the JSON Schema for the flow-spec DSL", description: - "Returns the JSON Schema for the `spec` object accepted by `flows.publish`'s `{ spec }` input and `flows.validate` — the authoritative reference for every step type's fields. Use `capabilities.get` first to resolve the names (templates, flows, tags, custom fields) a spec references into real ids.", + "Returns the JSON Schema for the `spec` object accepted by `flows.publish`'s, `flows.updateDraft`'s, and `flows.validate`'s `{ spec }` input — the authoritative reference for every step type's fields. Use `capabilities.get` first to resolve the names (templates, flows, tags, custom fields) a spec references into real ids.", tags: ["Capabilities"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/packages/business/__tests__/capabilities-service.test.ts b/packages/business/__tests__/capabilities-service.test.ts index ba0462fe8a..be12a759f0 100644 --- a/packages/business/__tests__/capabilities-service.test.ts +++ b/packages/business/__tests__/capabilities-service.test.ts @@ -11,8 +11,15 @@ const { tagService } = await import("../src/tag/service") const { whatsappMessageTemplateService } = await import( "../src/whatsapp-message-template/service" ) -const { getCapabilities, getFlowAuthoringContext } = await import( - "../src/capabilities/service" +const { + getCapabilities, + getFlowAuthoringContext, + CAPABILITIES_INCLUDES, + DEFAULT_INCLUDES, + OPT_IN_INCLUDES, +} = await import("../src/capabilities/service") +const { capabilitiesResponseSchema } = await import( + "../src/capabilities/schema" ) const emptyListResult = { data: [], pageCount: 0 } @@ -78,7 +85,7 @@ describe("getCapabilities", () => { id: `tpl-${i}`, name: `Template ${i}`, language: "en", - status: "approved", + status: "APPROVED", components: [], })) vi.spyOn(tagService, "listActive").mockResolvedValue(manyTags as never) @@ -94,6 +101,44 @@ describe("getCapabilities", () => { expect(result.tags).toHaveLength(200) expect(result.templates).toHaveLength(200) }) + + test("DEFAULT_INCLUDES is every CAPABILITIES_INCLUDES entry except the opt-in ones (aiAgents)", () => { + expect(OPT_IN_INCLUDES).toEqual(["aiAgents"]) + expect(DEFAULT_INCLUDES).toEqual( + CAPABILITIES_INCLUDES.filter((include) => include !== "aiAgents"), + ) + expect(DEFAULT_INCLUDES).not.toContain("aiAgents") + }) + + test("response satisfies the shared capabilitiesResponseSchema", async () => { + vi.spyOn(inboxService, "list").mockResolvedValue({ + data: [{ id: "1", name: "Support", channel: "messenger" }], + pageCount: 1, + } as never) + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue([ + { + id: "2", + name: "welcome_promo", + language: "en", + status: "APPROVED", + components: [], + }, + ] as never) + vi.spyOn(tagService, "listActive").mockResolvedValue([ + { id: "3", name: "vip" }, + ] as never) + vi.spyOn(aiAgentService, "listAIAgents").mockResolvedValue({ + data: [{ id: "4", name: "Support agent" }], + pageCount: 1, + } as never) + + const result = await getCapabilities({ + workspaceId: "ws-1", + include: CAPABILITIES_INCLUDES, + }) + + expect(capabilitiesResponseSchema.safeParse(result).success).toBe(true) + }) }) describe("getFlowAuthoringContext", () => { @@ -103,7 +148,7 @@ describe("getFlowAuthoringContext", () => { id: "1001", name: "welcome_promo", language: "en", - status: "approved", + status: "APPROVED", components: [], }, ] as never) @@ -124,7 +169,7 @@ describe("getFlowAuthoringContext", () => { expect(ctx.templatesByName.get("welcome_promo")).toEqual({ id: "1001", language: "en", - status: "approved", + status: "APPROVED", }) expect(ctx.customFieldsByName.get("Plan")).toEqual({ id: "1002", diff --git a/packages/business/package.json b/packages/business/package.json index 78b97698ba..378440148b 100644 --- a/packages/business/package.json +++ b/packages/business/package.json @@ -10,6 +10,7 @@ "./audit": "./src/audit/index.ts", "./branding": "./src/platform/branding.ts", "./capabilities": "./src/capabilities/index.ts", + "./capabilities/schema": "./src/capabilities/schema.ts", "./contact-custom-field": "./src/contact-custom-field/index.ts", "./contact-custom-field-value": "./src/contact-custom-field/value-service.ts", "./contact-inbox": "./src/contact-inbox/index.ts", diff --git a/packages/business/src/capabilities/index.ts b/packages/business/src/capabilities/index.ts index 9376fea807..5c5f459590 100644 --- a/packages/business/src/capabilities/index.ts +++ b/packages/business/src/capabilities/index.ts @@ -1 +1,2 @@ +export * from "./schema" export * from "./service" diff --git a/packages/business/src/capabilities/schema.ts b/packages/business/src/capabilities/schema.ts new file mode 100644 index 0000000000..724b3f7087 --- /dev/null +++ b/packages/business/src/capabilities/schema.ts @@ -0,0 +1,109 @@ +import { waTemplateParamsSchema } from "@chatbotx.io/flow-config" +import { z } from "zod" + +export const capabilitiesInboxSchema = z + .object({ + id: z.string().describe("Inbox ID."), + name: z.string().describe("Inbox name."), + channel: z.string().describe("Inbox channel."), + }) + .describe("An inbox available in the workspace.") +export type CapabilitiesInbox = z.infer + +export const capabilitiesTemplateSchema = z + .object({ + id: z.string().describe("WhatsApp message template ID."), + name: z.string().describe("WhatsApp message template name."), + language: z.string().describe("WhatsApp message template language."), + status: z + .string() + .describe( + "WhatsApp message template status. Actual values are APPROVED, PENDING, or REJECTED.", + ), + params: waTemplateParamsSchema.describe( + "Parameters required to send the WhatsApp message template.", + ), + }) + .describe("A WhatsApp message template available in the workspace.") +export type CapabilitiesTemplate = z.infer + +export const capabilitiesFieldSchema = z + .object({ + id: z.string().describe("Field ID."), + name: z.string().describe("Field name."), + type: z.string().describe("Field value type."), + }) + .describe("A custom or bot field available in the workspace.") +export type CapabilitiesField = z.infer + +export const capabilitiesNamedEntitySchema = z + .object({ + id: z.string().describe("Entity ID."), + name: z.string().describe("Entity name."), + }) + .describe("A named workspace entity.") +export type CapabilitiesNamedEntity = z.infer< + typeof capabilitiesNamedEntitySchema +> + +export const capabilitiesFlowSpecStepTypeSchema = z + .object({ + type: z.string().describe("Flow-spec step type."), + description: z.string().describe("How to use this flow-spec step type."), + }) + .describe("A flow-spec step type supported by the flow authoring DSL.") +export type CapabilitiesFlowSpecStepType = z.infer< + typeof capabilitiesFlowSpecStepTypeSchema +> + +export const capabilitiesFlowSpecSchema = z + .object({ + stepTypes: z + .array(capabilitiesFlowSpecStepTypeSchema) + .describe("Supported flow-spec step types."), + waitUnits: z.array(z.string()).describe("Supported wait-step delay units."), + channels: z.array(z.string()).describe("Supported flow channels."), + }) + .describe("Reference data for authoring a flow spec.") +export type CapabilitiesFlowSpec = z.infer + +export const capabilitiesResponseSchema = z + .object({ + inboxes: z + .array(capabilitiesInboxSchema) + .optional() + .describe("Inboxes available in the workspace."), + templates: z + .array(capabilitiesTemplateSchema) + .optional() + .describe("WhatsApp message templates available in the workspace."), + customFields: z + .array(capabilitiesFieldSchema) + .optional() + .describe("Custom fields available in the workspace."), + botFields: z + .array(capabilitiesFieldSchema) + .optional() + .describe("Bot fields available as reference data only."), + tags: z + .array(capabilitiesNamedEntitySchema) + .optional() + .describe("Tags available in the workspace."), + aiAgents: z + .array(capabilitiesNamedEntitySchema) + .optional() + .describe("AI agents available in the workspace."), + sequences: z + .array(capabilitiesNamedEntitySchema) + .optional() + .describe("Sequences available in the workspace."), + flows: z + .array(capabilitiesNamedEntitySchema) + .optional() + .describe("Flows available in the workspace."), + flowSpec: capabilitiesFlowSpecSchema + .optional() + .describe("Reference data for authoring a flow spec."), + }) + .describe("Workspace capabilities available to an agent.") +export type CapabilitiesResponse = z.infer diff --git a/packages/business/src/capabilities/service.ts b/packages/business/src/capabilities/service.ts index a658b028ab..2f10f332ee 100644 --- a/packages/business/src/capabilities/service.ts +++ b/packages/business/src/capabilities/service.ts @@ -1,7 +1,7 @@ import type { FlowAuthoringContext, + FlowSpecStepType, TemplateComponent, - WaTemplateParams, } from "@chatbotx.io/flow-config" import { extractTemplateParams, @@ -17,6 +17,24 @@ import { inboxService } from "../inbox/service" import { sequenceService } from "../sequence/service" import { tagService } from "../tag/service" import { whatsappMessageTemplateService } from "../whatsapp-message-template/service" +import type { + CapabilitiesField, + CapabilitiesFlowSpec, + CapabilitiesInbox, + CapabilitiesNamedEntity, + CapabilitiesResponse, + CapabilitiesTemplate, +} from "./schema" + +export type { + CapabilitiesField, + CapabilitiesFlowSpec, + CapabilitiesFlowSpecStepType, + CapabilitiesInbox, + CapabilitiesNamedEntity, + CapabilitiesResponse, + CapabilitiesTemplate, +} from "./schema" /** * Caps every list this service gathers. This output is fed straight into an @@ -47,45 +65,10 @@ export type CapabilitiesInclude = (typeof CAPABILITIES_INCLUDES)[number] // needs alongside them. `aiAgents` is left out of the default: it's rarely // needed to build a flow and the same information is one `ai_agents_list` // call away. -const DEFAULT_INCLUDES: readonly CapabilitiesInclude[] = [ - "inboxes", - "templates", - "customFields", - "botFields", - "tags", - "sequences", - "flows", - "flowSpec", -] - -export type CapabilitiesInbox = { id: string; name: string; channel: string } -export type CapabilitiesTemplate = { - id: string - name: string - language: string - status: string - params: WaTemplateParams -} -export type CapabilitiesField = { id: string; name: string; type: string } -export type CapabilitiesNamedEntity = { id: string; name: string } -export type CapabilitiesFlowSpecStepType = { type: string; description: string } -export type CapabilitiesFlowSpec = { - stepTypes: CapabilitiesFlowSpecStepType[] - waitUnits: string[] - channels: string[] -} - -export type CapabilitiesResponse = { - inboxes?: CapabilitiesInbox[] - templates?: CapabilitiesTemplate[] - customFields?: CapabilitiesField[] - botFields?: CapabilitiesField[] - tags?: CapabilitiesNamedEntity[] - aiAgents?: CapabilitiesNamedEntity[] - sequences?: CapabilitiesNamedEntity[] - flows?: CapabilitiesNamedEntity[] - flowSpec?: CapabilitiesFlowSpec -} +export const OPT_IN_INCLUDES: readonly CapabilitiesInclude[] = ["aiAgents"] +export const DEFAULT_INCLUDES = CAPABILITIES_INCLUDES.filter( + (include) => !OPT_IN_INCLUDES.includes(include), +) function toCapabilitiesField(field: { id: string @@ -113,6 +96,7 @@ async function listTemplates( const templates = await whatsappMessageTemplateService.list({ where: { workspaceId }, }) + // follow-up: tagService.listActive / whatsappMessageTemplateService.list have no limit param; capabilities slices in memory. return templates.slice(0, CAPABILITIES_LIST_LIMIT).map((template) => ({ id: template.id, name: template.name, @@ -146,6 +130,7 @@ async function listTags( workspaceId: string, ): Promise { const tags = await tagService.listActive({ workspaceId }) + // follow-up: tagService.listActive / whatsappMessageTemplateService.list have no limit param; capabilities slices in memory. return tags.slice(0, CAPABILITIES_LIST_LIMIT) } @@ -182,8 +167,10 @@ async function listFlows( } function getFlowSpecCapabilities(): CapabilitiesFlowSpec { + const stepTypes: FlowSpecStepType[] = flowSpecStepTypes + return { - stepTypes: flowSpecStepTypes, + stepTypes, waitUnits: [...waitStepDelayUnits.options], channels: [...channelTypes.options], } @@ -241,6 +228,9 @@ export async function getCapabilities(props: { export async function getFlowAuthoringContext( workspaceId: string, ): Promise { + // Deliberately uncached: an agent can create a template then immediately + // reference it in the same session, and a cache TTL would cause false + // `unknownTemplate` errors. const [templates, customFields, flows] = await Promise.all([ listTemplates(workspaceId), listCustomFields(workspaceId), From 597d51cba827bd226525e85e9c66e8aeb77afdd9 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:02:04 +0700 Subject: [PATCH 06/38] fix(mcp): harden mcp-server request handling and dedupe flow compile glue apps/mcp-server: bounds request bodies (413), adds fetch timeouts (AbortSignal), negative-caches failed token introspection, fixes NaN-limit and array-arguments bugs in meta-tools, serializes query params recursively (bracket notation) instead of String(), merges allOf/anyOf composite body schemas into tool input schemas, appends a token-scope paragraph to tool descriptions, replaces the hand-rolled result/lookup/dispatch helpers with shared ones, adopts the SDK's isInitializeRequest, wraps the request listener in try/catch, trims CHATBOTX_API_KEY at parse time, implements the previously-dead CHATBOTX_ALLOW_SELF_SIGNED_CERT env var, and fixes stale flows_create/tool-count doc drift in README/SKILL. apps/builder: compile-spec-to-graph.ts's two public compilers now share a private compileWithContext helper instead of duplicating context-fetch + compile; public-spec-mcp.test.ts imports the real WORKSPACE_TOKEN_SECURITY_SCHEMES instead of redeclaring it and gains a parity check between the mcp-server docs' tool tables and the live default tool set. --- .../builder/__tests__/public-spec-mcp.test.ts | 108 ++++++++- .../flows/lib/compile-spec-to-graph.ts | 17 +- apps/mcp-server/.env.example | 3 + apps/mcp-server/README.md | 4 +- apps/mcp-server/SKILL.md | 29 ++- .../mcp-server/__tests__/execute-tool.test.ts | 85 +++++++ apps/mcp-server/__tests__/meta-tools.test.ts | 52 ++++- .../__tests__/openapi-loader.test.ts | 197 +++++++++++++++++ apps/mcp-server/__tests__/sse-server.test.ts | 155 ++++++++++++- .../__tests__/token-introspection.test.ts | 25 +++ apps/mcp-server/src/env.ts | 7 +- apps/mcp-server/src/http.ts | 20 ++ apps/mcp-server/src/index.ts | 8 + apps/mcp-server/src/openapi-loader.ts | 193 ++++++++++++---- .../src/server/create-mcp-server.ts | 33 +-- apps/mcp-server/src/server/execute-tool.ts | 104 +++++---- apps/mcp-server/src/server/meta-tools.ts | 72 +++--- apps/mcp-server/src/server/sse-server.ts | 209 +++++++++++------- apps/mcp-server/src/token-introspection.ts | 61 +++-- 19 files changed, 1106 insertions(+), 276 deletions(-) create mode 100644 apps/mcp-server/__tests__/execute-tool.test.ts create mode 100644 apps/mcp-server/src/http.ts diff --git a/apps/builder/__tests__/public-spec-mcp.test.ts b/apps/builder/__tests__/public-spec-mcp.test.ts index 1725e748f2..607de5bae4 100644 --- a/apps/builder/__tests__/public-spec-mcp.test.ts +++ b/apps/builder/__tests__/public-spec-mcp.test.ts @@ -1,8 +1,12 @@ // @vitest-environment node +import { readFileSync } from "node:fs" +import { join } from "node:path" + import { OpenAPIGenerator } from "@orpc/openapi" import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4" import { beforeAll, describe, expect, test, vi } from "vitest" +import { WORKSPACE_TOKEN_SECURITY_SCHEMES } from "@/lib/orpc/public-spec" // Same side-effect-free import stubs as public-spec-operations.test.ts — // `@/routers/public` transitively boots the real db client and better-auth @@ -42,11 +46,9 @@ type McpSpecOperation = { // `oo.spec`-wrapping `requireTokenScope` in `apps/builder/src/orpc.ts` // actually ran. A channel-token-only or unauthenticated operation is exempt: // it never becomes an MCP tool, so it never needs a scope. -const WORKSPACE_TOKEN_SECURITY_SCHEMES = new Set([ - "bearerAuth", - "developerAccessToken", - "tokenInSearchParams", -]) +const workspaceTokenSecuritySchemeNames = new Set( + Object.keys(WORKSPACE_TOKEN_SECURITY_SCHEMES), +) function isWorkspaceTokenOperation(operation: McpSpecOperation): boolean { if (!operation.security) { @@ -54,7 +56,7 @@ function isWorkspaceTokenOperation(operation: McpSpecOperation): boolean { } return operation.security.some((requirement) => Object.keys(requirement).some((scheme) => - WORKSPACE_TOKEN_SECURITY_SCHEMES.has(scheme), + workspaceTokenSecuritySchemeNames.has(scheme), ), ) } @@ -65,6 +67,53 @@ function isWorkspaceTokenOperation(operation: McpSpecOperation): boolean { // table), never as a side effect of an unrelated change. const MAX_DEFAULT_VISIBLE_OPERATIONS = 45 +const MCP_SERVER_ROOT = join(import.meta.dirname, "..", "..", "mcp-server") +const MCP_README_PATH = join(MCP_SERVER_ROOT, "README.md") +const MCP_SKILL_PATH = join(MCP_SERVER_ROOT, "SKILL.md") +const MCP_README_TOOLS_HEADING = "## Available tools" +const MCP_README_PREREQUISITES_HEADING = "## Prerequisites" +const MCP_SKILL_CATEGORY_TABLE_HEADING = "| Category | Tool |" + +function sectionBetween( + source: string, + startHeading: string, + endHeading: string, +): string { + const start = source.indexOf(startHeading) + const end = source.indexOf(endHeading, start + startHeading.length) + if (start === -1 || end === -1) { + throw new Error( + `Could not find documentation section between "${startHeading}" and "${endHeading}".`, + ) + } + return source.slice(start, end) +} +function mcpToolNamesFromMarkdownTable( + source: string, + toolColumnIndex: number, +): Set { + const toolNames = new Set() + for (const row of source.split("\n")) { + const cell = row.split("|")[toolColumnIndex] + if (!cell) { + continue + } + for (const match of cell.matchAll(/`([^`]+)`/g)) { + toolNames.add(match[1].replace(/[._]/g, "").toLowerCase()) + } + } + return toolNames +} + +function mcpSkillCategoryTable(source: string): string { + const start = source.indexOf(MCP_SKILL_CATEGORY_TABLE_HEADING) + const end = source.indexOf("\n\n", start) + if (start === -1 || end === -1) { + throw new Error("Could not find the MCP SKILL.md category table.") + } + return source.slice(start, end) +} + let operations: McpSpecOperation[] beforeAll(async () => { @@ -136,6 +185,53 @@ describe("default tool set", () => { expect(missingDescription).toEqual([]) }) + test("README and SKILL list exactly the default MCP tools", () => { + const defaultToolNames = new Set( + defaultOperations().map((operation) => + operation.operationId.replace(/[._]/g, "").toLowerCase(), + ), + ) + const documentedToolNames = { + README: mcpToolNamesFromMarkdownTable( + sectionBetween( + readFileSync(MCP_README_PATH, "utf8"), + MCP_README_TOOLS_HEADING, + MCP_README_PREREQUISITES_HEADING, + ), + 1, + ), + SKILL: mcpToolNamesFromMarkdownTable( + mcpSkillCategoryTable(readFileSync(MCP_SKILL_PATH, "utf8")), + 2, + ), + } + + const differences = Object.fromEntries( + Object.entries(documentedToolNames).map(([document, toolNames]) => [ + document, + { + documentedButNotDefault: [...toolNames] + .filter((toolName) => !defaultToolNames.has(toolName)) + .sort(), + defaultButNotDocumented: [...defaultToolNames] + .filter((toolName) => !toolNames.has(toolName)) + .sort(), + }, + ]), + ) + + expect(differences).toEqual({ + README: { + documentedButNotDefault: [], + defaultButNotDocumented: [], + }, + SKILL: { + documentedButNotDefault: [], + defaultButNotDocumented: [], + }, + }) + }) + // A diff here means the default surface changed — intentional per P0.2's // curated table, never a byproduct of an unrelated route edit. Update the // snapshot only alongside a deliberate addition/removal. diff --git a/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts index 92271ec371..20a755cc94 100644 --- a/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts +++ b/apps/builder/src/features/flows/lib/compile-spec-to-graph.ts @@ -1,5 +1,6 @@ import { getFlowAuthoringContext } from "@chatbotx.io/business/capabilities" import { + type CompiledFlow, compileFlowSpec, type EdgeSchema, FlowAuthoringException, @@ -10,6 +11,13 @@ import { } from "@chatbotx.io/flow-config" import { publishFlowSchema } from "../schema/action" +async function compileWithContext( + spec: FlowSpec, + workspaceId: string, +): Promise { + return compileFlowSpec(spec, await getFlowAuthoringContext(workspaceId)) +} + /** * Resolves a `{ spec }` flow-authoring request into the raw `{ nodes, edges }` * graph shape `flowVersionService` persists — the single place @@ -20,8 +28,7 @@ export async function compileSpecToGraph( spec: FlowSpec, workspaceId: string, ): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> { - const ctx = await getFlowAuthoringContext(workspaceId) - const { nodes, edges } = compileFlowSpec(spec, ctx) + const { nodes, edges } = await compileWithContext(spec, workspaceId) return { nodes, edges } } @@ -64,8 +71,10 @@ export async function compileAndValidateSpec( spec: FlowSpec, workspaceId: string, ): Promise<{ nodes: FlowVersionSchema[]; edges: EdgeSchema[] }> { - const ctx = await getFlowAuthoringContext(workspaceId) - const { nodes, edges, specPathByNodeId } = compileFlowSpec(spec, ctx) + const { nodes, edges, specPathByNodeId } = await compileWithContext( + spec, + workspaceId, + ) const result = publishFlowSchema.safeParse({ nodes, edges }) if (!result.success) { diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example index eb3c2fc28b..92155c03eb 100644 --- a/apps/mcp-server/.env.example +++ b/apps/mcp-server/.env.example @@ -16,6 +16,9 @@ CHATBOTX_ALLOW_SELF_SIGNED_CERT= # before the next tools/list call triggers a background re-fetch, in ms. CHATBOTX_SPEC_TTL_MS=300000 +# Maximum duration for an OpenAPI, token introspection, or tool call HTTP request, in ms. +CHATBOTX_HTTP_TIMEOUT_MS=30000 + # ─── MCP Server (SSE / HTTP transport only) ────────────────────────────────── # Transport mode: "stdio" | "sse" | "both" diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 16769900e8..54b55ea543 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -32,7 +32,7 @@ Use `search_tools` when the task needs something outside the default set (e.g. d |---|---| | `capabilities_get` | Discover the workspace's inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — the ids a flow spec or a message needs to reference. | | `token_get` | Get the calling token's workspace id, permission (`read_only`/`full`), and scopes — check before attempting a write. | -| `schemas_flow_spec` | Get the JSON Schema for the `spec` object `flows_create`/`flows_publish`/`flows_validate` accept — the authoritative reference for every flow step type. | +| `schemas_flow_spec` | Get the JSON Schema for the `spec` object `flows_publish`/`flows_update_draft`/`flows_validate` accept — the authoritative reference for every flow step type. | ## Available tools @@ -243,6 +243,7 @@ cp .env.example .env | `CHATBOTX_API_URL` | ChatbotX API origin, including `/api` (e.g. `https://app.chatbotx.io/api`) | `https://api.chatbotx.io` | Yes | | `CHATBOTX_ALLOW_SELF_SIGNED_CERT` | Disable TLS verification (`true`/`false`) | — | No | | `CHATBOTX_SPEC_TTL_MS` | How long the fetched OpenAPI spec/tool list and a token's introspected scopes are trusted before a background re-fetch | `300000` | No | +| `CHATBOTX_HTTP_TIMEOUT_MS` | Maximum duration for each OpenAPI, token introspection, or tool-call HTTP request | `30000` | No | | `CHATBOTX_MCP_TRANSPORT` | `stdio` \| `sse` \| `both` | `both` | No | | `CHATBOTX_MCP_HOST` | SSE server host | `0.0.0.0` | No | | `CHATBOTX_MCP_PORT` | SSE server port | `3333` | No | @@ -277,6 +278,7 @@ dotenv -e .env -- tsx src/test-tools.ts src/ ├── index.ts # Entry point — loads spec, starts transport(s) ├── env.ts # Environment variable schema +├── http.ts # Timed fetch helper for OpenAPI, token, and tool requests ├── openapi-loader.ts # Fetches OpenAPI spec → DynamicTool list, x-mcp │ # visibility/scope parsing, scope-filtered getVisibleTools() ├── token-introspection.ts # GET /v1/token → cached {permission, scopes} per token diff --git a/apps/mcp-server/SKILL.md b/apps/mcp-server/SKILL.md index 5e12480fbe..a2eff61a6e 100644 --- a/apps/mcp-server/SKILL.md +++ b/apps/mcp-server/SKILL.md @@ -73,7 +73,7 @@ Find your workspace token at: **Settings → Developer → API Keys** ```bash claude mcp add chatbotx \ -e CHATBOTX_API_KEY= \ - -e CHATBOTX_API_URL=https://your-instance.com \ + -e CHATBOTX_API_URL=https://your-instance.com/api \ -e CHATBOTX_MCP_TRANSPORT=stdio \ -s user \ -- node /path/to/dist/index.mjs @@ -224,7 +224,7 @@ chatbotx error-logs list # [--page --perPage --sort ## MCP Tools (for AI agents) -Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 44 tools** — not the full ~350-operation API — plus two meta-tools that reach everything else: +Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 43 tools** — not the full ~350-operation API — plus two meta-tools that reach everything else: | Tool | Description | |---|---| @@ -237,22 +237,21 @@ Call `capabilities_get` and `token_get` first — both are always visible regard |---|---| | `capabilities_get` | Discover the workspace's inboxes, templates, fields, tags, sequences, and flows — the ids other tools need. | | `token_get` | Get the calling token's workspace id, permission (`read_only`/`full`), and scopes. | -| `schemas_flow_spec` | JSON Schema for the flow-spec DSL `flows_create`/`flows_publish`/`flows_validate` accept. | +| `schemas_flow_spec` | JSON Schema for the flow-spec DSL `flows_publish`/`flows_update_draft`/`flows_validate` accept. | | Category | Tool | |---|---| -| Analytics | `analytics_contacts_count`, `analytics_new_contacts_count` | -| Broadcasts | `broadcasts_create`, `broadcasts_list`, `broadcasts_schedule`, `broadcasts_stop` | -| Contacts | `contacts_add_tags`, `contacts_count`, `contacts_create`, `contacts_get`, `contacts_list`, `contacts_list_filter_fields`, `contacts_search`, `contacts_send_flow`, `contacts_send_message`, `contacts_set_custom_fields`, `contacts_subscribe_sequences`, `contacts_update`, `contacts_upsert` | -| Conversations | `conversations_archive`, `conversations_assign`, `conversations_get`, `conversations_list` | -| Custom Fields | `custom_fields_create`, `custom_fields_list` | -| Flows | `flows_create`, `flows_get`, `flows_list`, `flows_publish`, `flows_validate` | -| Inboxes | `inboxes_list` | -| Keywords | `keywords_create`, `keywords_list` | -| Messages | `messages_create`, `messages_list` | -| Sequences | `sequences_create`, `sequences_list` | -| Tags | `tags_create`, `tags_list` | -| Triggers | `triggers_create`, `triggers_list` | +| Capabilities | `capabilities_get`, `schemas_flow_spec`, `token_get` | +| AI Agents | `ai_agents_list`, `ai_agents_create`, `ai_agents_update`, `ai_files_list`, `ai_functions_list` | +| Analytics | `analytics_new_contact_counts_per_day`, `analytics_blocked_contacts_per_day`, `analytics_flow_stats`, `analytics_broadcast_stats`, `analytics_sequence_step_stats` | +| Broadcasts | `broadcasts_list`, `broadcasts_get`, `broadcasts_stop` | +| Contacts | `contacts_create`, `contacts_get`, `contacts_list`, `contacts_search`, `contacts_list_tags`, `contacts_add_tags_by_name`, `contacts_list_custom_fields`, `contacts_set_custom_field`, `contacts_list_messages`, `contacts_send_message`, `contacts_send_flow`, `contacts_list_sequences`, `contacts_subscribe_sequences` | +| Conversations | `conversations_list`, `conversations_get`, `conversations_assign` | +| Error Logs | `error_logs_list` | +| Flows | `flows_list`, `flows_get`, `flows_create`, `flows_update_draft`, `flows_publish` | +| Keywords | `keywords_list` | +| Messages | `messages_list` | +| Sequences | `sequences_list`, `sequences_get`, `sequences_update` | A token missing a scope, or a `read_only` token calling a write tool, does not see that tool in `tools/list` (the underlying API call still 403s if forced via `call_tool`). Tools are auto-generated from the OpenAPI spec — new default-visible endpoints appear automatically once the spec's TTL (`CHATBOTX_SPEC_TTL_MS`, default 5 minutes) elapses, no restart required. diff --git a/apps/mcp-server/__tests__/execute-tool.test.ts b/apps/mcp-server/__tests__/execute-tool.test.ts new file mode 100644 index 0000000000..8962f19800 --- /dev/null +++ b/apps/mcp-server/__tests__/execute-tool.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test, vi } from "vitest" +import type { DynamicTool } from "../src/openapi-loader" +import { executeTool } from "../src/server/execute-tool" + +const tool: DynamicTool = { + alwaysVisible: false, + annotations: { + destructiveHint: false, + idempotentHint: true, + readOnlyHint: true, + }, + baseUrl: "https://api.example.com", + bodyParamNames: [], + description: "List contacts", + inputSchema: { properties: {}, type: "object" }, + method: "GET", + name: "contacts_list", + pathParamNames: [], + pathTemplate: "/v1/contacts", + queryParamNames: ["page", "include", "contactFilter"], + visibility: "default", +} + +const successfulResponse = { + headers: { get: () => "application/json" }, + json: async () => ({ data: [] }), + ok: true, +} + +describe("executeTool", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("passes an AbortSignal timeout to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue(successfulResponse) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await executeTool(tool, {}, "api-key") + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + signal: expect.any(AbortSignal), + }) + }) + + test("reports a timeout with the configured timeout duration", async () => { + globalThis.fetch = vi + .fn() + .mockRejectedValue( + new DOMException("Timed out", "TimeoutError"), + ) as unknown as typeof fetch + + const result = await executeTool(tool, {}, "api-key") + + expect(result).toEqual({ + content: [{ text: "Request timed out after 30000ms", type: "text" }], + isError: true, + }) + }) + + test("serializes nested query parameters with bracket notation", async () => { + const fetchMock = vi.fn().mockResolvedValue(successfulResponse) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await executeTool( + tool, + { + contactFilter: { operator: "and" }, + include: ["tags", "flows"], + page: 2, + }, + "api-key", + ) + + const requestedUrl = new URL(String(fetchMock.mock.calls[0]?.[0])) + expect([...requestedUrl.searchParams.entries()]).toEqual([ + ["page", "2"], + ["include[0]", "tags"], + ["include[1]", "flows"], + ["contactFilter[operator]", "and"], + ]) + }) +}) diff --git a/apps/mcp-server/__tests__/meta-tools.test.ts b/apps/mcp-server/__tests__/meta-tools.test.ts index fa2802df17..11bec3096a 100644 --- a/apps/mcp-server/__tests__/meta-tools.test.ts +++ b/apps/mcp-server/__tests__/meta-tools.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { META_TOOLS } from "../src/server/meta-tools" // Same convention as openapi-loader.test.ts: `getCachedTools()` is // module-level state populated by `loadOpenApiSpec()`, so each test needs a @@ -32,15 +33,11 @@ const specWithTools = ( }) describe("META_TOOLS", () => { - test("are exactly search_tools and call_tool", async () => { - const { META_TOOLS, META_TOOL_NAMES } = await import( - "../src/server/meta-tools" - ) + test("are exactly search_tools and call_tool", () => { expect(META_TOOLS.map((tool) => tool.name)).toEqual([ "search_tools", "call_tool", ]) - expect([...META_TOOL_NAMES].sort()).toEqual(["call_tool", "search_tools"]) }) }) @@ -108,6 +105,22 @@ describe("searchTools", () => { expect(searchTools("keyword", 100)).toHaveLength(25) }) + test("uses the default limit when limit is NaN", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + specWithTools([{ name: "tags.list", summary: "Get all tags" }]), + ) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + const { searchTools } = await import("../src/server/meta-tools") + + expect(searchTools("tags", Number.NaN).map((tool) => tool.name)).toEqual([ + "tags_list", + ]) + }) + test("a GET tool ranks above a same-scoring non-GET tool", async () => { globalThis.fetch = vi.fn().mockResolvedValue( specWithTools([ @@ -225,4 +238,33 @@ describe("handleCallTool", () => { ) expect(result.isError).toBeUndefined() }) + + test("rejects an arguments array without executing a fetch", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + specWithTools([{ name: "tags.list", summary: "Get all tags" }]), + ) as unknown as typeof fetch + const { loadOpenApiSpec } = await import("../src/openapi-loader") + await loadOpenApiSpec() + + const executeFetch = vi.fn() + globalThis.fetch = executeFetch as unknown as typeof fetch + const { handleCallTool } = await import("../src/server/meta-tools") + const result = await handleCallTool( + { arguments: [], name: "tags_list" }, + "api-key", + ) + + expect(result).toEqual({ + content: [ + { + text: "call_tool 'arguments' must be a JSON object.", + type: "text", + }, + ], + isError: true, + }) + expect(executeFetch).not.toHaveBeenCalled() + }) }) diff --git a/apps/mcp-server/__tests__/openapi-loader.test.ts b/apps/mcp-server/__tests__/openapi-loader.test.ts index 744b5e9bd7..4d54386612 100644 --- a/apps/mcp-server/__tests__/openapi-loader.test.ts +++ b/apps/mcp-server/__tests__/openapi-loader.test.ts @@ -128,6 +128,178 @@ describe("loadOpenApiSpec", () => { "List tags", ) }) + + test("adds a scope requirement to a GET tool description", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => null }, + json: async () => ({ + paths: { + "/v1/tags": { + get: { + operationId: "tags.list", + summary: "List tags", + "x-mcp": { scope: "contacts" }, + }, + }, + }, + }), + ok: true, + }) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + + expect(tool?.description).toBe( + "List tags\n\nRequires token scope: contacts.", + ) + }) + + test("adds scope and full-token requirements to a POST tool description", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => null }, + json: async () => ({ + paths: { + "/v1/tags": { + post: { + operationId: "tags.create", + summary: "Create tag", + "x-mcp": { scope: "contacts" }, + }, + }, + }, + }), + ok: true, + }) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + + expect(tool?.description).toBe( + "Create tag\n\nRequires token scope: contacts.\nRequires a full (non read-only) token.", + ) + }) + + test("adds the full-token requirement when readOnlyHint is explicitly false", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => null }, + json: async () => ({ + paths: { + "/v1/tags": { + post: { + operationId: "tags.create", + summary: "Create tag", + "x-mcp": { readOnlyHint: false, scope: "contacts" }, + }, + }, + }, + }), + ok: true, + }) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + + expect(tool?.description).toBe( + "Create tag\n\nRequires token scope: contacts.\nRequires a full (non read-only) token.", + ) + }) + + test("merges allOf request body properties and requirements", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => null }, + json: async () => ({ + paths: { + "/v1/messages": { + post: { + operationId: "messages.create", + requestBody: { + content: { + "application/json": { + schema: { + allOf: [ + { + properties: { content: { type: "string" } }, + required: ["content"], + type: "object", + }, + { + properties: { contactId: { type: "string" } }, + required: ["contactId"], + type: "object", + }, + ], + }, + }, + }, + }, + }, + }, + }, + }), + ok: true, + }) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + + expect(tool?.bodyParamNames).toEqual(["content", "contactId"]) + expect(tool?.inputSchema).toEqual({ + properties: { + contactId: { type: "string" }, + content: { type: "string" }, + }, + required: ["content", "contactId"], + type: "object", + }) + }) + + test("merges oneOf request body properties without marking branch fields required", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => null }, + json: async () => ({ + paths: { + "/v1/messages": { + post: { + operationId: "messages.create", + requestBody: { + content: { + "application/json": { + schema: { + oneOf: [ + { + properties: { text: { type: "string" } }, + required: ["text"], + type: "object", + }, + { + properties: { templateId: { type: "string" } }, + required: ["templateId"], + type: "object", + }, + ], + }, + }, + }, + }, + }, + }, + }, + }), + ok: true, + }) as unknown as typeof fetch + + const { loadOpenApiSpec } = await import("../src/openapi-loader") + const [tool] = await loadOpenApiSpec() + + expect(tool?.bodyParamNames).toEqual(["text", "templateId"]) + expect(tool?.inputSchema).toEqual({ + properties: { + templateId: { type: "string" }, + text: { type: "string" }, + }, + type: "object", + }) + }) }) describe("refreshOpenApiSpecIfStale", () => { @@ -406,6 +578,31 @@ describe("getVisibleTools", () => { expect(getVisibleTools().map((t) => t.name)).toEqual(["tags_list"]) }) + test("looks up hidden tools without exposing Object prototype properties", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { + "/v1/minigames": { + get: { operationId: "minigames.list", summary: "List minigames" }, + }, + }, + }), + }) as unknown as typeof fetch + + const { getToolByName, loadOpenApiSpec } = await import( + "../src/openapi-loader" + ) + await loadOpenApiSpec() + + expect(getToolByName("minigames_list")?.visibility).toBe("hidden") + expect(getToolByName("unknown_tool")).toBeUndefined() + expect(getToolByName("toString")).toBeUndefined() + expect(getToolByName("constructor")).toBeUndefined() + }) + const specWithScopedTools = () => ({ ok: true, headers: { get: () => null }, diff --git a/apps/mcp-server/__tests__/sse-server.test.ts b/apps/mcp-server/__tests__/sse-server.test.ts index bca1882a95..72f5a25709 100644 --- a/apps/mcp-server/__tests__/sse-server.test.ts +++ b/apps/mcp-server/__tests__/sse-server.test.ts @@ -1,4 +1,9 @@ -import type { IncomingMessage } from "node:http" +import { + createServer, + type IncomingMessage, + request, + type ServerResponse, +} from "node:http" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" // Dynamic per-test `import()` (not a static top-level import) matches this @@ -83,7 +88,7 @@ describe("makeApiKeyState / updateApiKeyStateFromRequest", () => { beforeEach(() => { vi.resetModules() - process.env.CHATBOTX_API_KEY = "env-default-token" + process.env.CHATBOTX_API_KEY = " env-default-token " }) afterEach(() => { @@ -132,3 +137,149 @@ describe("makeApiKeyState / updateApiKeyStateFromRequest", () => { expect(state.current).toBe("token-a") }) }) + +type RequestListener = ( + req: IncomingMessage, + res: ServerResponse, +) => Promise + +const startRequestServer = async (listener: RequestListener) => { + const server = createServer(listener) + const { promise: listening, resolve } = Promise.withResolvers() + server.listen(0, "127.0.0.1", resolve) + await listening + + const address = server.address() + if (!address || typeof address === "string") { + throw new Error("Expected a TCP listener address") + } + + return { + close: async (): Promise => { + const { promise, reject, resolve } = Promise.withResolvers() + server.close((error) => (error ? reject(error) : resolve())) + await promise + }, + url: `http://127.0.0.1:${address.port}`, + } +} + +const sendChunkedRequest = async ( + url: string, + chunks: Buffer[], +): Promise<{ statusCode: number }> => { + const { promise, reject, resolve } = Promise.withResolvers<{ + statusCode: number + }>() + const clientRequest = request(url, { method: "POST" }, (incomingResponse) => { + incomingResponse.resume() + incomingResponse.on("end", () => { + resolve({ statusCode: incomingResponse.statusCode ?? 0 }) + }) + }) + clientRequest.on("error", reject) + for (const chunk of chunks) { + clientRequest.write(chunk) + } + clientRequest.end() + return await promise +} + +describe("createRequestListener", () => { + test("rejects an oversized content-length before creating an MCP server", async () => { + const { createRequestListener } = await import("../src/server/sse-server") + const createMcpServer = vi.fn() + const requestServer = await startRequestServer( + createRequestListener(createMcpServer as never), + ) + + try { + const oversizedBody = "x".repeat(1024 * 1024 + 1) + const response = await fetch(`${requestServer.url}/messages`, { + body: oversizedBody, + headers: { "content-length": String(Buffer.byteLength(oversizedBody)) }, + method: "POST", + }) + + expect(response.status).toBe(413) + expect(createMcpServer).not.toHaveBeenCalled() + } finally { + await requestServer.close() + } + }) + + test("rejects a streamed body that grows past the size limit", async () => { + const { createRequestListener } = await import("../src/server/sse-server") + const createMcpServer = vi.fn() + const requestServer = await startRequestServer( + createRequestListener(createMcpServer as never), + ) + + try { + const response = await sendChunkedRequest( + `${requestServer.url}/messages`, + [Buffer.alloc(1024 * 1024), Buffer.from("x")], + ) + + expect(response.statusCode).toBe(413) + expect(createMcpServer).not.toHaveBeenCalled() + } finally { + await requestServer.close() + } + }) + + test("returns 400 for malformed JSON", async () => { + const { createRequestListener } = await import("../src/server/sse-server") + const createMcpServer = vi.fn() + const requestServer = await startRequestServer( + createRequestListener(createMcpServer as never), + ) + + try { + const response = await fetch(`${requestServer.url}/messages`, { + body: "{not json", + method: "POST", + }) + + expect(response.status).toBe(400) + expect(createMcpServer).not.toHaveBeenCalled() + } finally { + await requestServer.close() + } + }) + + test("logs and returns 500 when MCP server creation throws", async () => { + const { createRequestListener } = await import("../src/server/sse-server") + const createMcpServer = vi.fn(() => { + throw new Error("create failed") + }) + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined) + const requestServer = await startRequestServer( + createRequestListener(createMcpServer as never), + ) + + try { + const response = await fetch(`${requestServer.url}/messages`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + protocolVersion: "2025-03-26", + }, + }), + method: "POST", + }) + + expect(response.status).toBe(500) + expect(errorSpy).toHaveBeenCalledTimes(1) + } finally { + errorSpy.mockRestore() + await requestServer.close() + } + }) +}) diff --git a/apps/mcp-server/__tests__/token-introspection.test.ts b/apps/mcp-server/__tests__/token-introspection.test.ts index 85866a0d88..5da8f0013a 100644 --- a/apps/mcp-server/__tests__/token-introspection.test.ts +++ b/apps/mcp-server/__tests__/token-introspection.test.ts @@ -123,6 +123,31 @@ describe("introspectToken", () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + test("caches a failed introspection for 30 seconds", async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue({ ok: false }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-negative-cache") + vi.advanceTimersByTime(29_999) + await introspectToken("token-negative-cache") + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test("re-fetches a failed introspection after 30 seconds", async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue({ ok: false }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { introspectToken } = await import("../src/token-introspection") + await introspectToken("token-negative-cache-expiry") + vi.advanceTimersByTime(30_001) + await introspectToken("token-negative-cache-expiry") + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) test("evicts expired cache entries once a subsequent lookup inserts a new one", async () => { vi.useFakeTimers() const fetchMock = vi.fn().mockResolvedValue({ diff --git a/apps/mcp-server/src/env.ts b/apps/mcp-server/src/env.ts index 34b46c76e2..2648e76c2e 100644 --- a/apps/mcp-server/src/env.ts +++ b/apps/mcp-server/src/env.ts @@ -3,7 +3,7 @@ import { z } from "zod" export const env = createEnv({ server: { - CHATBOTX_API_KEY: z.string().default(""), + CHATBOTX_API_KEY: z.string().trim().default(""), CHATBOTX_API_URL: z.url().default("https://api.chatbotx.io"), CHATBOTX_ALLOW_SELF_SIGNED_CERT: z.enum(["true", "false"]).optional(), CHATBOTX_MCP_TRANSPORT: z.enum(["stdio", "sse", "both"]).default("both"), @@ -24,6 +24,11 @@ export const env = createEnv({ // a new/changed public endpoint never appeared without restarting the // server. CHATBOTX_SPEC_TTL_MS: z.coerce.number().int().positive().default(300_000), + CHATBOTX_HTTP_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(30_000), }, runtimeEnv: process.env, }) diff --git a/apps/mcp-server/src/http.ts b/apps/mcp-server/src/http.ts new file mode 100644 index 0000000000..4300f2ba78 --- /dev/null +++ b/apps/mcp-server/src/http.ts @@ -0,0 +1,20 @@ +export const fetchWithTimeout = async ( + url: string | URL, + init: RequestInit, + timeoutMs: number, +): Promise => { + try { + return await globalThis.fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (error) { + if (!(error instanceof Error) || error.name !== "TimeoutError") { + throw error + } + + const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`) + timeoutError.name = "TimeoutError" + throw timeoutError + } +} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index a950c4cb4e..515800373c 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -6,6 +6,14 @@ import { runSseServer } from "./server/sse-server" import { runStdioServer } from "./server/stdio-server" async function main() { + // Manual verification only: this intentionally mutates Node's global TLS policy. + if (env.CHATBOTX_ALLOW_SELF_SIGNED_CERT === "true") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" + console.error( + "CHATBOTX_ALLOW_SELF_SIGNED_CERT=true disables TLS certificate verification.", + ) + } + await loadOpenApiSpec() if (env.CHATBOTX_MCP_TRANSPORT === "both") { diff --git a/apps/mcp-server/src/openapi-loader.ts b/apps/mcp-server/src/openapi-loader.ts index 6017368e79..8feeed8921 100644 --- a/apps/mcp-server/src/openapi-loader.ts +++ b/apps/mcp-server/src/openapi-loader.ts @@ -1,4 +1,5 @@ import { env } from "./env" +import { fetchWithTimeout } from "./http" import type { TokenIntrospection } from "./token-introspection" interface OpenAPISpec { @@ -21,7 +22,6 @@ interface OpenAPIOperation { operationId?: string parameters?: OpenAPIParameter[] requestBody?: { - required?: boolean content?: { "application/json"?: { schema?: OpenAPISchemaObject @@ -112,6 +112,8 @@ export interface DynamicTool { } let cachedTools: DynamicTool[] | null = null + +const toolsByName = new Map() let cachedEtag: string | null = null let fetchedAtMs = 0 // Coalesces concurrent refresh attempts (e.g. several `tools/list` calls @@ -165,13 +167,83 @@ function buildAnnotations( * (instead of preferring one) means an endpoint author never has to choose * which one the MCP tool actually sees. */ -function buildToolDescription(operation: OpenAPIOperation): string { +function buildToolDescription( + operation: OpenAPIOperation, + meta: McpOperationMeta | undefined, + annotations: DynamicToolAnnotations, +): string { const parts = [operation.summary, operation.description].filter( (part): part is string => Boolean(part), ) + const requirements: string[] = [] + if (meta?.scope) { + requirements.push(`Requires token scope: ${meta.scope}.`) + } + if (!annotations.readOnlyHint) { + requirements.push("Requires a full (non read-only) token.") + } + if (requirements.length > 0) { + parts.push(requirements.join("\n")) + } + return parts.length > 0 ? parts.join("\n\n") : (operation.operationId ?? "") } +const resolveBodySchema = ( + bodySchema: OpenAPISchemaObject | undefined, +): + | { + properties: Record + required: string[] + } + | undefined => { + if (!bodySchema) { + return + } + + if (bodySchema.properties) { + return { + properties: bodySchema.properties, + required: bodySchema.required ?? [], + } + } + + if (bodySchema.allOf) { + const properties: Record = {} + const required: string[] = [] + for (const branch of bodySchema.allOf) { + for (const [key, value] of Object.entries(branch.properties ?? {})) { + properties[key] = value + } + for (const key of branch.required ?? []) { + if (!required.includes(key)) { + required.push(key) + } + } + } + return Object.keys(properties).length > 0 + ? { properties, required } + : undefined + } + + const variants = [...(bodySchema.anyOf ?? []), ...(bodySchema.oneOf ?? [])] + if (variants.length === 0) { + return + } + + const properties: Record = {} + for (const branch of variants) { + for (const [key, value] of Object.entries(branch.properties ?? {})) { + properties[key] = value + } + } + // Branch requirements are intentionally omitted: per-property anyOf wrappers + // would be more precise, but are noisier for an LLM tool schema. + return Object.keys(properties).length > 0 + ? { properties, required: [] } + : undefined +} + function buildInputSchema(operation: OpenAPIOperation): { schema: DynamicTool["inputSchema"] bodyParamNames: string[] @@ -201,14 +273,15 @@ function buildInputSchema(operation: OpenAPIOperation): { } } - const bodySchema = - operation.requestBody?.content?.["application/json"]?.schema - if (bodySchema?.properties) { + const bodySchema = resolveBodySchema( + operation.requestBody?.content?.["application/json"]?.schema, + ) + if (bodySchema) { for (const [key, value] of Object.entries(bodySchema.properties)) { properties[key] = value bodyParamNames.push(key) } - for (const key of bodySchema.required ?? []) { + for (const key of bodySchema.required) { if (!required.includes(key)) { required.push(key) } @@ -226,45 +299,55 @@ function buildInputSchema(operation: OpenAPIOperation): { } } +function parseOperation( + pathTemplate: string, + method: string, + operation: OpenAPIOperation, + baseUrl: string, +): DynamicTool | null { + if ( + !operation.operationId || + operation.deprecated || + !isWorkspaceTokenOperation(operation) + ) { + return null + } + + const { schema, bodyParamNames, queryParamNames } = + buildInputSchema(operation) + const meta = operation["x-mcp"] + const annotations = buildAnnotations(method, meta) + return { + alwaysVisible: meta?.alwaysVisible === true, + annotations, + baseUrl, + bodyParamNames, + description: buildToolDescription(operation, meta, annotations), + inputSchema: schema, + method: method.toUpperCase(), + name: toSnakeCase(operation.operationId), + pathParamNames: extractPathParamNames(pathTemplate), + pathTemplate, + queryParamNames, + scope: meta?.scope, + visibility: meta?.visibility === "default" ? "default" : "hidden", + } +} + function parseToolsFromSpec(spec: OpenAPISpec): DynamicTool[] { const baseUrl = spec.servers?.[0]?.url ?? env.CHATBOTX_API_URL const tools: DynamicTool[] = [] for (const [pathTemplate, pathItem] of Object.entries(spec.paths ?? {})) { - for (const [httpMethod, operation] of Object.entries(pathItem)) { - if (!HTTP_METHODS.has(httpMethod)) { - continue - } - if (!operation.operationId) { - continue - } - if (operation.deprecated) { - continue - } - if (!isWorkspaceTokenOperation(operation)) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) { continue } - const pathParamNames = extractPathParamNames(pathTemplate) - const { schema, bodyParamNames, queryParamNames } = - buildInputSchema(operation) - - const meta = operation["x-mcp"] - tools.push({ - name: toSnakeCase(operation.operationId), - description: buildToolDescription(operation), - inputSchema: schema, - baseUrl, - pathTemplate, - method: httpMethod.toUpperCase(), - pathParamNames, - bodyParamNames, - queryParamNames, - visibility: meta?.visibility === "default" ? "default" : "hidden", - alwaysVisible: meta?.alwaysVisible === true, - scope: meta?.scope, - annotations: buildAnnotations(httpMethod, meta), - }) + const tool = parseOperation(pathTemplate, method, operation, baseUrl) + if (tool) { + tools.push(tool) + } } } @@ -282,12 +365,16 @@ function parseToolsFromSpec(spec: OpenAPISpec): DynamicTool[] { async function fetchAndParseSpec(): Promise { const specUrl = `${env.CHATBOTX_API_URL}/public-spec.json` - const response = await fetch(specUrl, { - headers: { - Accept: "application/json", - ...(cachedEtag ? { "If-None-Match": cachedEtag } : {}), + const response = await fetchWithTimeout( + specUrl, + { + headers: { + Accept: "application/json", + ...(cachedEtag ? { "If-None-Match": cachedEtag } : {}), + }, }, - }) + env.CHATBOTX_HTTP_TIMEOUT_MS, + ) if (response.status === 304 && cachedTools !== null) { fetchedAtMs = Date.now() @@ -303,6 +390,17 @@ async function fetchAndParseSpec(): Promise { const spec = (await response.json()) as OpenAPISpec const tools = parseToolsFromSpec(spec) + toolsByName.clear() + for (const tool of tools) { + if (toolsByName.has(tool.name)) { + console.error( + `Duplicate MCP tool name "${tool.name}" in OpenAPI spec; keeping the first operation.`, + ) + continue + } + toolsByName.set(tool.name, tool) + } + cachedTools = tools cachedEtag = response.headers.get("ETag") fetchedAtMs = Date.now() @@ -359,6 +457,9 @@ export function getCachedTools(): DynamicTool[] { return cachedTools ?? [] } +export const getToolByName = (name: string): DynamicTool | undefined => + toolsByName.get(name) + function isVisibleForScope( tool: DynamicTool, introspection: TokenIntrospection | null, @@ -400,7 +501,9 @@ function isVisibleForScope( export function getVisibleTools( introspection?: TokenIntrospection | null, ): DynamicTool[] { - return getCachedTools() - .filter((tool) => tool.visibility === "default") - .filter((tool) => isVisibleForScope(tool, introspection ?? null)) + return getCachedTools().filter( + (tool) => + tool.visibility === "default" && + isVisibleForScope(tool, introspection ?? null), + ) } diff --git a/apps/mcp-server/src/server/create-mcp-server.ts b/apps/mcp-server/src/server/create-mcp-server.ts index 29b2f3533f..feca1132d3 100644 --- a/apps/mcp-server/src/server/create-mcp-server.ts +++ b/apps/mcp-server/src/server/create-mcp-server.ts @@ -8,16 +8,14 @@ import { version as packageVersion, } from "../../package.json" import { env } from "../env" -import { getVisibleTools, refreshOpenApiSpecIfStale } from "../openapi-loader" -import { introspectToken } from "../token-introspection" -import { executeTool } from "./execute-tool" import { - findToolByName, - handleCallTool, - handleSearchTools, - META_TOOL_NAMES, - META_TOOLS, -} from "./meta-tools" + getToolByName, + getVisibleTools, + refreshOpenApiSpecIfStale, +} from "../openapi-loader" +import { introspectToken } from "../token-introspection" +import { errorResult, executeTool } from "./execute-tool" +import { handleCallTool, handleSearchTools, META_TOOLS } from "./meta-tools" export type CreateMcpServerOptions = { getApiKey?: () => string @@ -44,8 +42,7 @@ export const createMcpServer = ( { instructions: env.CHATBOTX_MCP_SERVER_INSTRUCTIONS }, ) - const getApiKey = (): string => - options?.getApiKey?.().trim() || env.CHATBOTX_API_KEY + const getApiKey = (): string => options?.getApiKey?.() || env.CHATBOTX_API_KEY // Bypass McpServer's high-level tool API to support raw JSON Schema from // the OpenAPI spec. We register handlers on the underlying low-level server. @@ -82,22 +79,16 @@ export const createMcpServer = ( const apiKey = getApiKey() if (!apiKey) { - return { - isError: true, - content: [{ type: "text" as const, text: NO_API_KEY_MESSAGE }], - } + return errorResult(NO_API_KEY_MESSAGE) } - if (META_TOOL_NAMES.has(name)) { + if (name === "call_tool") { return await handleCallTool(toolArgs, apiKey) } - const tool = findToolByName(name) + const tool = getToolByName(name) if (!tool) { - return { - isError: true, - content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], - } + return errorResult(`Unknown tool: ${name}`) } return await executeTool(tool, toolArgs, apiKey) diff --git a/apps/mcp-server/src/server/execute-tool.ts b/apps/mcp-server/src/server/execute-tool.ts index 61069b6532..54c3d8b33f 100644 --- a/apps/mcp-server/src/server/execute-tool.ts +++ b/apps/mcp-server/src/server/execute-tool.ts @@ -1,10 +1,38 @@ +import { env } from "../env" +import { fetchWithTimeout } from "../http" import type { DynamicTool } from "../openapi-loader" const NO_BODY_METHODS: ReadonlySet = new Set(["GET", "HEAD", "DELETE"]) -function buildQueryString(params: Record): string { - const qs = new URLSearchParams(params).toString() - return qs ? `?${qs}` : "" +const appendQueryParam = ( + params: URLSearchParams, + key: string, + value: unknown, +): void => { + if (value === undefined || value === null) { + return + } + + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + appendQueryParam(params, `${key}[${index}]`, item) + } + return + } + + if (typeof value === "object") { + for (const [childKey, childValue] of Object.entries(value)) { + appendQueryParam(params, `${key}[${childKey}]`, childValue) + } + return + } + + params.append(key, String(value)) +} + +const buildQueryString = (params: URLSearchParams): string => { + const queryString = params.toString() + return queryString ? `?${queryString}` : "" } export type ToolCallResult = { @@ -12,6 +40,15 @@ export type ToolCallResult = { isError?: boolean } +export const errorResult = (text: string): ToolCallResult => ({ + content: [{ text, type: "text" }], + isError: true, +}) + +export const jsonResult = (value: unknown): ToolCallResult => ({ + content: [{ text: JSON.stringify(value, null, 2), type: "text" }], +}) + /** * Fires the HTTP request a `DynamicTool` describes. Shared by the normal * `tools/call` path (`create-mcp-server.ts`) and `call_tool` @@ -28,25 +65,14 @@ export async function executeTool( for (const paramName of tool.pathParamNames) { const value = args[paramName] if (value === undefined || value === null) { - return { - isError: true, - content: [ - { - type: "text", - text: `Missing required path parameter: ${paramName}`, - }, - ], - } + return errorResult(`Missing required path parameter: ${paramName}`) } path = path.replace(`{${paramName}}`, encodeURIComponent(String(value))) } - const queryArgs: Record = {} + const queryParams = new URLSearchParams() for (const key of tool.queryParamNames) { - const value = args[key] - if (value !== undefined && value !== null) { - queryArgs[key] = String(value) - } + appendQueryParam(queryParams, key, args[key]) } const body: Record = {} @@ -56,19 +82,23 @@ export async function executeTool( } } - const url = `${tool.baseUrl}${path}${buildQueryString(queryArgs)}` + const url = `${tool.baseUrl}${path}${buildQueryString(queryParams)}` const sendBody = !NO_BODY_METHODS.has(tool.method) && tool.bodyParamNames.length > 0 try { - const response = await fetch(url, { - method: tool.method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + const response = await fetchWithTimeout( + url, + { + body: sendBody ? JSON.stringify(body) : undefined, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + method: tool.method, }, - body: sendBody ? JSON.stringify(body) : undefined, - }) + env.CHATBOTX_HTTP_TIMEOUT_MS, + ) let result: unknown const contentType = response.headers.get("content-type") ?? "" @@ -79,25 +109,17 @@ export async function executeTool( } if (!response.ok) { - return { - isError: true, - content: [ - { - type: "text", - text: `Error ${response.status}:\n${JSON.stringify(result, null, 2)}`, - }, - ], - } + return errorResult( + `Error ${response.status}:\n${JSON.stringify(result, null, 2)}`, + ) } - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - } + return jsonResult(result) } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error" - return { - isError: true, - content: [{ type: "text", text: `Request failed: ${message}` }], + if (error instanceof Error && error.name === "TimeoutError") { + return errorResult(error.message) } + const message = error instanceof Error ? error.message : "Unknown error" + return errorResult(`Request failed: ${message}`) } } diff --git a/apps/mcp-server/src/server/meta-tools.ts b/apps/mcp-server/src/server/meta-tools.ts index cae3ab90c8..f42cb77d31 100644 --- a/apps/mcp-server/src/server/meta-tools.ts +++ b/apps/mcp-server/src/server/meta-tools.ts @@ -1,5 +1,14 @@ -import { type DynamicTool, getCachedTools } from "../openapi-loader" -import { executeTool, type ToolCallResult } from "./execute-tool" +import { + type DynamicTool, + getCachedTools, + getToolByName, +} from "../openapi-loader" +import { + errorResult, + executeTool, + jsonResult, + type ToolCallResult, +} from "./execute-tool" /** * Static tool definitions for the two meta-tools that give an agent access @@ -47,10 +56,6 @@ export const META_TOOLS = [ }, ] as const -export const META_TOOL_NAMES: ReadonlySet = new Set( - META_TOOLS.map((tool) => tool.name), -) - const DEFAULT_SEARCH_LIMIT = 10 const MAX_SEARCH_LIMIT = 25 // A name/description-token match is worth less than a whole-phrase match, @@ -125,7 +130,12 @@ export function searchTools(query: string, limit?: number): DynamicTool[] { const queryPhrase = query.trim().toLowerCase() const queryTokens = tokenize(query) const cappedLimit = Math.min( - Math.max(limit ?? DEFAULT_SEARCH_LIMIT, 1), + Math.max( + limit !== undefined && Number.isFinite(limit) + ? limit + : DEFAULT_SEARCH_LIMIT, + 1, + ), MAX_SEARCH_LIMIT, ) @@ -150,10 +160,6 @@ export function searchTools(query: string, limit?: number): DynamicTool[] { .map(({ tool }) => tool) } -export function findToolByName(name: string): DynamicTool | undefined { - return getCachedTools().find((tool) => tool.name === name) -} - /** * `search_tools` handler — validates the raw MCP `arguments` object and * returns each match's name/description/inputSchema as JSON text, the same @@ -165,15 +171,7 @@ export function handleSearchTools( ): ToolCallResult { const query = args.query if (typeof query !== "string" || query.trim().length === 0) { - return { - isError: true, - content: [ - { - type: "text", - text: "search_tools requires a non-empty 'query' string.", - }, - ], - } + return errorResult("search_tools requires a non-empty 'query' string.") } const limit = typeof args.limit === "number" ? args.limit : undefined @@ -183,9 +181,7 @@ export function handleSearchTools( inputSchema: tool.inputSchema, })) - return { - content: [{ type: "text", text: JSON.stringify(matches, null, 2) }], - } + return jsonResult(matches) } /** @@ -199,26 +195,26 @@ export async function handleCallTool( ): Promise { const name = args.name if (typeof name !== "string" || name.trim().length === 0) { - return { - isError: true, - content: [ - { type: "text", text: "call_tool requires a non-empty 'name' string." }, - ], - } + return errorResult("call_tool requires a non-empty 'name' string.") } - const tool = findToolByName(name) + const tool = getToolByName(name) if (!tool) { - return { - isError: true, - content: [{ type: "text", text: `Unknown tool: ${name}` }], - } + return errorResult(`Unknown tool: ${name}`) + } + + const suppliedArguments = args.arguments + if ( + suppliedArguments !== undefined && + (typeof suppliedArguments !== "object" || + suppliedArguments === null || + Array.isArray(suppliedArguments) || + Object.getPrototypeOf(suppliedArguments) !== Object.prototype) + ) { + return errorResult("call_tool 'arguments' must be a JSON object.") } - const toolArguments = - args.arguments && typeof args.arguments === "object" - ? (args.arguments as Record) - : {} + const toolArguments = (suppliedArguments ?? {}) as Record return await executeTool(tool, toolArguments, apiKey) } diff --git a/apps/mcp-server/src/server/sse-server.ts b/apps/mcp-server/src/server/sse-server.ts index 4dee88276d..bd865b439e 100644 --- a/apps/mcp-server/src/server/sse-server.ts +++ b/apps/mcp-server/src/server/sse-server.ts @@ -7,6 +7,7 @@ import { import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js" import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" import { env } from "../env" import type { CreateMcpServerOptions } from "./create-mcp-server" @@ -33,9 +34,31 @@ type LegacySseSession = { transport: SSEServerTransport } +type CreateMcpServerFn = (options?: CreateMcpServerOptions) => McpServer + +type ResolvedSession = + | { kind: "streamable"; session: SseSession } + | { kind: "legacy"; session: LegacySseSession } + const sseSessions = new Map() const legacySseSessions = new Map() +const MAX_BODY_BYTES = 1024 * 1024 + +class PayloadTooLargeError extends Error {} + +const resolveSession = (sessionId: string): ResolvedSession | undefined => { + const streamableSession = sseSessions.get(sessionId) + if (streamableSession) { + return { kind: "streamable", session: streamableSession } + } + + const legacySession = legacySseSessions.get(sessionId) + if (legacySession) { + return { kind: "legacy", session: legacySession } + } +} + const apiTokenHeaderNames = ["x-workspace-token", "x-chatbo-token"] as const export const resolveHeaderValue = ( @@ -108,10 +131,22 @@ const enableCors = (res: ServerResponse): void => { } const parseRequestBody = async (req: IncomingMessage): Promise => { - const chunks: Buffer[] = [] + const contentLength = Number( + resolveHeaderValue(req.headers["content-length"]), + ) + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + throw new PayloadTooLargeError() + } + const chunks: Buffer[] = [] + let bodyBytes = 0 for await (const chunk of req) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk) + const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk + bodyBytes += buffer.byteLength + if (bodyBytes > MAX_BODY_BYTES) { + throw new PayloadTooLargeError() + } + chunks.push(buffer) } if (chunks.length === 0) { @@ -140,15 +175,6 @@ const setSessionIdHeader = (req: IncomingMessage, sessionId: string): void => { req.headers["mcp-session-id"] = sessionId } -const isInitializeRequest = (value: unknown): boolean => { - if (!value || typeof value !== "object") { - return false - } - - const candidate = value as { method?: unknown } - return candidate.method === "initialize" -} - const writePlainText = ( res: ServerResponse, statusCode: number, @@ -162,7 +188,7 @@ const writePlainText = ( const handleSseRequest = async ( req: IncomingMessage, res: ServerResponse, - createMcpServer: (options?: CreateMcpServerOptions) => McpServer, + createMcpServer: CreateMcpServerFn, ): Promise => { if (req.method === "OPTIONS") { res.statusCode = 204 @@ -176,8 +202,6 @@ const handleSseRequest = async ( } const sessionId = getSessionId(req) - - // No session ID → old SSE protocol (Claude Desktop, Claude CLI -t sse) if (!sessionId) { const apiKeyState = makeApiKeyState(req) const server = createMcpServer({ @@ -192,27 +216,61 @@ const handleSseRequest = async ( server, transport, }) - res.on("close", () => legacySseSessions.delete(transport.sessionId)) + transport.onclose = () => { + legacySseSessions.delete(transport.sessionId) + } await server.connect(transport) return } - // Has session ID → Streamable HTTP GET for server-initiated messages - const session = sseSessions.get(sessionId) - if (!session) { + const resolvedSession = resolveSession(sessionId) + if (resolvedSession?.kind !== "streamable") { writePlainText(res, 404, "Unknown sessionId") return } + const { session } = resolvedSession updateApiKeyStateFromRequest(session.apiKeyState, req) setSessionIdHeader(req, sessionId) await session.transport.handleRequest(req, res) } +const startStreamableSession = async ( + req: IncomingMessage, + res: ServerResponse, + parsedBody: unknown, + createMcpServer: CreateMcpServerFn, +): Promise => { + const apiKeyState = makeApiKeyState(req) + const server = createMcpServer({ + getApiKey: getApiKeyFromState(apiKeyState), + }) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (initializedSessionId) => { + sseSessions.set(initializedSessionId, { + apiKeyState, + server, + transport, + }) + }, + }) + + transport.onclose = () => { + const activeSessionId = transport.sessionId + if (activeSessionId) { + sseSessions.delete(activeSessionId) + } + } + + await server.connect(transport) + await transport.handleRequest(req, res, parsedBody) +} + const handleMessagesRequest = async ( req: IncomingMessage, res: ServerResponse, - createMcpServer: (options?: CreateMcpServerOptions) => McpServer, + createMcpServer: CreateMcpServerFn, ): Promise => { if (req.method === "OPTIONS") { res.statusCode = 204 @@ -231,22 +289,28 @@ const handleMessagesRequest = async ( const parsedBody = await parseRequestBody(req) if (sessionId) { - const streamableSession = sseSessions.get(sessionId) - if (streamableSession) { - updateApiKeyStateFromRequest(streamableSession.apiKeyState, req) - setSessionIdHeader(req, sessionId) - await streamableSession.transport.handleRequest(req, res, parsedBody) + const resolvedSession = resolveSession(sessionId) + if (!resolvedSession) { + writePlainText(res, 404, "Unknown sessionId") return } - const legacySession = legacySseSessions.get(sessionId) - if (legacySession) { - updateApiKeyStateFromRequest(legacySession.apiKeyState, req) - await legacySession.transport.handlePostMessage(req, res, parsedBody) + updateApiKeyStateFromRequest(resolvedSession.session.apiKeyState, req) + if (resolvedSession.kind === "streamable") { + setSessionIdHeader(req, sessionId) + await resolvedSession.session.transport.handleRequest( + req, + res, + parsedBody, + ) return } - writePlainText(res, 404, "Unknown sessionId") + await resolvedSession.session.transport.handlePostMessage( + req, + res, + parsedBody, + ) return } @@ -259,30 +323,7 @@ const handleMessagesRequest = async ( return } - const apiKeyState = makeApiKeyState(req) - const server = createMcpServer({ - getApiKey: getApiKeyFromState(apiKeyState), - }) - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (initializedSessionId) => { - sseSessions.set(initializedSessionId, { - apiKeyState, - server, - transport, - }) - }, - }) - - transport.onclose = () => { - const activeSessionId = transport.sessionId - if (activeSessionId) { - sseSessions.delete(activeSessionId) - } - } - - await server.connect(transport) - await transport.handleRequest(req, res, parsedBody) + await startStreamableSession(req, res, parsedBody, createMcpServer) } catch (error) { if (error instanceof SyntaxError) { writePlainText(res, 400, "Invalid JSON body") @@ -292,35 +333,51 @@ const handleMessagesRequest = async ( } } -export const runSseServer = async ( - createMcpServer: (options?: CreateMcpServerOptions) => McpServer, -): Promise => { - const httpServer = createServer(async (req, res) => { - enableCors(res) +export const createRequestListener = + (createMcpServer: CreateMcpServerFn) => + async (req: IncomingMessage, res: ServerResponse): Promise => { + try { + enableCors(res) - const url = new URL(req.url ?? "", "http://localhost") + const url = new URL(req.url ?? "", "http://localhost") + if (url.pathname === env.CHATBOTX_MCP_SSE_PATH) { + await handleSseRequest(req, res, createMcpServer) + return + } - if (url.pathname === env.CHATBOTX_MCP_SSE_PATH) { - await handleSseRequest(req, res, createMcpServer) - return - } + if (url.pathname === env.CHATBOTX_MCP_MESSAGES_PATH) { + await handleMessagesRequest(req, res, createMcpServer) + return + } - if (url.pathname === env.CHATBOTX_MCP_MESSAGES_PATH) { - await handleMessagesRequest(req, res, createMcpServer) - return - } + if (url.pathname === "/") { + writePlainText(res, 200, "MCP SSE server is running") + return + } - if (url.pathname === "/") { - writePlainText(res, 200, "MCP SSE server is running") - return - } + writePlainText(res, 404, "Not Found") + } catch (error) { + if (error instanceof PayloadTooLargeError) { + writePlainText(res, 413, "Payload Too Large") + return + } - writePlainText(res, 404, "Not Found") - }) + console.error("MCP SSE request failed:", error) + if (res.headersSent) { + res.end() + return + } + writePlainText(res, 500, "Internal Server Error") + } + } - await new Promise((resolve) => { - httpServer.listen(env.CHATBOTX_MCP_PORT, env.CHATBOTX_MCP_HOST, resolve) - }) +export const runSseServer = async ( + createMcpServer: CreateMcpServerFn, +): Promise => { + const httpServer = createServer(createRequestListener(createMcpServer)) + const { promise: listening, resolve } = Promise.withResolvers() + httpServer.listen(env.CHATBOTX_MCP_PORT, env.CHATBOTX_MCP_HOST, resolve) + await listening console.error( `MCP Server running on http://${env.CHATBOTX_MCP_HOST}:${env.CHATBOTX_MCP_PORT}${env.CHATBOTX_MCP_SSE_PATH}`, diff --git a/apps/mcp-server/src/token-introspection.ts b/apps/mcp-server/src/token-introspection.ts index a79b24ac47..ede7c87db4 100644 --- a/apps/mcp-server/src/token-introspection.ts +++ b/apps/mcp-server/src/token-introspection.ts @@ -1,5 +1,6 @@ import { z } from "zod" import { env } from "./env" +import { fetchWithTimeout } from "./http" const tokenIntrospectionSchema = z.object({ workspaceId: z.string(), @@ -15,21 +16,36 @@ export type TokenIntrospection = z.infer * caching by session id would serve a stale/wrong scope set after a token * swap. Module-level, same pattern as `openapi-loader.ts`'s tool cache. */ -const introspectionCache = new Map< - string, - { data: TokenIntrospection; fetchedAtMs: number } ->() +const NEGATIVE_CACHE_TTL_MS = 30_000 + +type IntrospectionCacheEntry = { + data: TokenIntrospection | null + fetchedAtMs: number + ttlMs: number +} + +const introspectionCache = new Map() /** Sweeps entries whose TTL has already elapsed so the cache can't grow unbounded across distinct tokens. */ function evictExpiredEntries(): void { - const cutoffMs = Date.now() - env.CHATBOTX_SPEC_TTL_MS + const nowMs = Date.now() for (const [apiKey, entry] of introspectionCache) { - if (entry.fetchedAtMs < cutoffMs) { + if (nowMs - entry.fetchedAtMs >= entry.ttlMs) { introspectionCache.delete(apiKey) } } } +const cacheIntrospectionResult = ( + apiKey: string, + data: TokenIntrospection | null, + ttlMs: number, +): TokenIntrospection | null => { + evictExpiredEntries() + introspectionCache.set(apiKey, { data, fetchedAtMs: Date.now(), ttlMs }) + return data +} + /** * Resolves a workspace token's scopes/permission via `GET /v1/token`, for * scope-based `tools/list` filtering. Returns `null` on any failure @@ -42,39 +58,42 @@ export async function introspectToken( apiKey: string, ): Promise { const cached = introspectionCache.get(apiKey) - if (cached && Date.now() - cached.fetchedAtMs < env.CHATBOTX_SPEC_TTL_MS) { + if (cached && Date.now() - cached.fetchedAtMs < cached.ttlMs) { return cached.data } try { - const response = await fetch(`${env.CHATBOTX_API_URL}/v1/token`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${apiKey}`, + const response = await fetchWithTimeout( + `${env.CHATBOTX_API_URL}/v1/token`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, }, - }) + env.CHATBOTX_HTTP_TIMEOUT_MS, + ) if (!response.ok) { - return null + return cacheIntrospectionResult(apiKey, null, NEGATIVE_CACHE_TTL_MS) } const parsed = tokenIntrospectionSchema.safeParse(await response.json()) if (!parsed.success) { console.error( `Token introspection returned a malformed body, tools/list will not be scope-filtered: ${parsed.error.message}`, ) - return null + return cacheIntrospectionResult(apiKey, null, NEGATIVE_CACHE_TTL_MS) } - evictExpiredEntries() - introspectionCache.set(apiKey, { - data: parsed.data, - fetchedAtMs: Date.now(), - }) - return parsed.data + return cacheIntrospectionResult( + apiKey, + parsed.data, + env.CHATBOTX_SPEC_TTL_MS, + ) } catch (error) { console.error( `Token introspection failed, tools/list will not be scope-filtered: ${ error instanceof Error ? error.message : String(error) }`, ) - return null + return cacheIntrospectionResult(apiKey, null, NEGATIVE_CACHE_TTL_MS) } } From 766883e78066e0280234f6707b21802485c3ef18 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:16:04 +0700 Subject: [PATCH 07/38] feat(api): describe public API paging/id-param helpers and default flows.validate WS1 batch 0 infra: apps/builder/src/lib/public-api/list.ts describes publicListResponse's data/pageCount and gains PUBLIC_LIST_PAGING_NOTE/withListPagingNote; new lib/public-api/params.ts adds publicIdParam/describeId factories for the id-param pattern repeated across public routers. flows.validate becomes a default MCP tool (44 total) alongside description improvements on its flows.list/get/create/duplicate/publish siblings. public-spec-operations.test.ts and public-spec-mcp.test.ts gain description/tag/summary-style/field-description assertions gated by a DESCRIPTION_BACKLOG ratchet that subsequent batches shrink. mcp-server README/SKILL tool count corrected to 44 with flows_validate listed. Also carries partial in-progress summary/description work on several other routers (ads, ai-agents, analytics, appointments, broadcasts, capabilities, contacts, conversations, coupons, error-logs, media-library, product-categories, sequences, tags, token, triggers) that follow-up commits complete. --- .../public-spec-mcp.test.ts.snap | 1 + .../builder/__tests__/public-spec-mcp.test.ts | 22 +- .../__tests__/public-spec-operations.test.ts | 226 +++++++++++++++++- .../src/features/ads-campaign/api/public.ts | 36 +-- apps/builder/src/features/ads/api/public.ts | 26 +- .../src/features/ai-agents/api/public.ts | 9 +- .../src/features/ai-files/api/public.ts | 3 +- .../src/features/ai-functions/api/public.ts | 3 +- .../src/features/analytics/api/public.ts | 12 +- .../src/features/appointments/api/public.ts | 2 +- .../features/automated-response/api/public.ts | 4 +- .../src/features/broadcasts/api/public.ts | 15 +- .../src/features/capabilities/api/public.ts | 3 +- .../features/contact-sequences/api/public.ts | 2 +- .../src/features/contacts/api/public/crud.ts | 11 +- .../contacts/api/public/custom-fields.ts | 4 +- .../features/contacts/api/public/messages.ts | 6 +- .../src/features/contacts/api/public/tags.ts | 2 +- .../src/features/conversations/api/public.ts | 6 +- .../src/features/coupons/api/public.ts | 2 +- .../src/features/error-logs/api/public.ts | 2 +- apps/builder/src/features/flows/api/public.ts | 14 +- .../src/features/media-library/api/public.ts | 3 +- .../features/product-categories/api/public.ts | 2 +- .../src/features/sequences/api/public.ts | 8 +- apps/builder/src/features/tags/api/public.ts | 3 +- apps/builder/src/features/token/api/public.ts | 2 +- .../src/features/triggers/api/public.ts | 3 +- apps/builder/src/lib/public-api/list.ts | 15 +- apps/builder/src/lib/public-api/params.ts | 14 ++ apps/mcp-server/README.md | 3 +- apps/mcp-server/SKILL.md | 4 +- 32 files changed, 355 insertions(+), 113 deletions(-) create mode 100644 apps/builder/src/lib/public-api/params.ts diff --git a/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap index ef5aa45c40..338056ea5b 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap @@ -38,6 +38,7 @@ exports[`default tool set > operation ids match the curated snapshot 1`] = ` "flows.list", "flows.publish", "flows.updateDraft", + "flows.validate", "keywords.list", "messages.list", "schemas.flowSpec", diff --git a/apps/builder/__tests__/public-spec-mcp.test.ts b/apps/builder/__tests__/public-spec-mcp.test.ts index 607de5bae4..a560fb8723 100644 --- a/apps/builder/__tests__/public-spec-mcp.test.ts +++ b/apps/builder/__tests__/public-spec-mcp.test.ts @@ -177,12 +177,24 @@ describe("default tool set", () => { expect(deleteDefaults).toEqual([]) }) - test("every default operation has a description, not just a summary", () => { - const missingDescription = defaultOperations() - .filter((op) => !op.description) - .map((op) => op.operationId) + test("every default operation has a useful MCP description", () => { + const invalidDescriptions = defaultOperations().flatMap((operation) => { + const description = operation.description + if (!description || description.length < 80) { + return operation.operationId + } + + const referencedOperationIds = [ + ...description.matchAll(/\b[a-z][A-Za-z]+\.[a-z][A-Za-z]+\b/g), + ].map(([operationId]) => operationId) + return referencedOperationIds.some( + (operationId) => operationId !== operation.operationId, + ) + ? [] + : operation.operationId + }) - expect(missingDescription).toEqual([]) + expect(invalidDescriptions).toEqual([]) }) test("README and SKILL list exactly the default MCP tools", () => { diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index b1b2fde424..c8d19c4648 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -1,6 +1,10 @@ // @vitest-environment node -import { OpenAPIGenerator } from "@orpc/openapi" +import { + type JSONSchema, + OpenAPIGenerator, + simplifyComposedObjectJsonSchemasAndRefs, +} from "@orpc/openapi" import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4" import { beforeAll, describe, expect, test, vi } from "vitest" @@ -23,19 +27,95 @@ vi.mock("@chatbotx.io/database/client", () => { return { db: proxy } }) +type JsonSchema = { + allOf?: JsonSchema[] + anyOf?: JsonSchema[] + description?: string + oneOf?: JsonSchema[] + properties?: Record + type?: string +} + type SpecOperation = { - operationId: string + bodySchema?: JsonSchema + description?: string method: string + operationId: string + parameters: Array<{ + description?: string + name: string + schema?: JsonSchema + }> path: string - tags: string[] - summary?: string - security?: Record[] responseStatuses: string[] + security?: Record[] + summary?: string + tags: string[] } const LEGACY_WORKSPACE_TOKEN_PATTERN = /workspace[_.]?token/i const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i +const DESCRIPTION_BACKLOG = new Set([ + "ads.", + "aiAgents.", + "aiFiles.", + "aiFunctions.", + "aiMcpServers.", + "analytics.", + "appointmentCalendars.", + "appointmentExternalCalendars.", + "appointmentReminders.", + "appointments.", + "botFields.", + "broadcasts.", + "capabilities.", + "channels.", + "contactScans.", + "contacts.", + "conversations.", + "coupons.", + "customFields.", + "dynamicImages.", + "emailTopics.", + "errorLogs.", + "externalWebhooks.", + "facebookLeadAds.", + "fbComments.", + "flows.", + "folders.", + "igComments.", + "igStories.", + "inboxTeams.", + "inboxes.", + "integrations.", + "keywords.", + "mediaLibrary.", + "messages.", + "messengerChannels.", + "messengerPersonas.", + "minigames.", + "productCategories.", + "products.", + "qrCodes.", + "questionnaires.", + "reflinks.", + "savedReplies.", + "schemas.", + "sequences.", + "smtpIntegrations.", + "spreadsheets.", + "tags.", + "templateMessages.", + "token.", + "triggers.", + "userPersistentMenus.", + "webchats.", + "webhooks.", + "workspaceMembers.", + "zaloChannels.", +]) + let operations: SpecOperation[] let responseSchemasByOperationId: Record let requestSchemasByOperationId: Record @@ -117,11 +197,16 @@ beforeAll(async () => { methods as Record, )) { const op = operation as { + description?: string operationId?: string summary?: string tags?: string[] security?: Record[] - parameters?: { schema?: unknown }[] + parameters?: Array<{ + description?: string + name: string + schema?: JsonSchema + }> requestBody?: { content?: Record } @@ -133,9 +218,18 @@ beforeAll(async () => { if (!op.operationId) { continue } + const bodySchema = op.requestBody?.content?.["application/json"]?.schema operations.push({ + bodySchema: bodySchema + ? (simplifyComposedObjectJsonSchemasAndRefs( + bodySchema as JSONSchema, + spec, + ) as JsonSchema) + : undefined, + description: op.description, operationId: op.operationId, method: method.toUpperCase(), + parameters: op.parameters ?? [], path, tags: op.tags ?? [], summary: op.summary, @@ -158,9 +252,10 @@ beforeAll(async () => { requestSchemas.push(param.schema) } } - const bodySchema = op.requestBody?.content?.["application/json"]?.schema - if (bodySchema) { - requestSchemas.push(bodySchema) + const requestBodySchema = + op.requestBody?.content?.["application/json"]?.schema + if (requestBodySchema) { + requestSchemas.push(requestBodySchema) } if (requestSchemas.length > 0) { requestSchemasByOperationId[op.operationId] = requestSchemas @@ -171,6 +266,32 @@ beforeAll(async () => { operations.sort((a, b) => a.operationId.localeCompare(b.operationId)) }, 120_000) +const isDescriptionBacklogged = (operationId: string): boolean => + DESCRIPTION_BACKLOG.has(operationId.slice(0, operationId.indexOf(".") + 1)) + +const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/ +const SUMMARY_STARTS_UPPERCASE_PATTERN = /^[A-Z]/ +const normalizeDescriptionPhrase = (value: string): string => { + const [firstToken = "", ...remainingTokens] = value + .toLowerCase() + .split(NON_ALPHANUMERIC_PATTERN) + .filter(Boolean) + const normalizedFirstToken = firstToken.endsWith("s") + ? firstToken.slice(0, -1) + : firstToken + return [normalizedFirstToken, ...remainingTokens].join("") +} + +const hasDescribedComposedBranches = (schema: JsonSchema): boolean => + (["allOf", "anyOf", "oneOf"] as const).some((combinator) => { + const branches = schema[combinator] + return ( + branches !== undefined && + branches.length > 0 && + branches.every((branch) => Boolean(branch.description)) + ) + }) + describe("public API spec — operation naming guard", () => { // Pins the MCP tool name / operationId surface. A diff here is a // deliberate, breaking rename of the public API surface — update the @@ -200,6 +321,93 @@ describe("public API spec — operation naming guard", () => { expect(missingSummary).toEqual([]) }) + test("every non-backlogged operation has a description", () => { + const missingDescriptions = operations + .filter((operation) => !isDescriptionBacklogged(operation.operationId)) + .filter((operation) => !operation.description) + .map((operation) => operation.operationId) + + expect(missingDescriptions).toEqual([]) + }) + + test("every non-backlogged operation has a tag", () => { + const missingTags = operations + .filter((operation) => !isDescriptionBacklogged(operation.operationId)) + .filter((operation) => operation.tags.length === 0) + .map((operation) => operation.operationId) + + expect(missingTags).toEqual([]) + }) + + test("every summary follows the public API house style", () => { + const invalidSummaries = operations.flatMap((operation) => { + const summary = operation.summary + if (!summary) { + return operation.operationId + } + + const isInvalid = + summary.length > 60 || + summary.endsWith(".") || + summary.includes(" — ") || + summary.includes(". ") || + !SUMMARY_STARTS_UPPERCASE_PATTERN.test(summary) + return isInvalid ? operation.operationId : [] + }) + + expect(invalidSummaries).toEqual([]) + }) + + test("every present description is useful and non-redundant", () => { + const invalidDescriptions = operations.flatMap((operation) => { + const description = operation.description + if (!description) { + return [] + } + + const duplicatesSummary = normalizeDescriptionPhrase( + description, + ).startsWith(normalizeDescriptionPhrase(operation.summary ?? "")) + const isInvalid = description.length < 50 || duplicatesSummary + return isInvalid ? operation.operationId : [] + }) + expect(invalidDescriptions).toEqual([]) + }) + + test("every non-backlogged top-level input field has a description", () => { + const missingInputDescriptions = operations.flatMap((operation) => { + if (isDescriptionBacklogged(operation.operationId)) { + return [] + } + + const missingParameters = operation.parameters + .filter( + (parameter) => + !(parameter.description || parameter.schema?.description), + ) + .map((parameter) => `${operation.operationId}.${parameter.name}`) + + const bodySchema = operation.bodySchema + if (!bodySchema) { + return missingParameters + } + if (bodySchema.type !== "object") { + return [...missingParameters, `${operation.operationId}.`] + } + + const missingBodyFields = Object.entries(bodySchema.properties ?? {}) + .filter( + ([, property]) => + !(property.description || hasDescribedComposedBranches(property)), + ) + .map(([name]) => `${operation.operationId}.${name}`) + + return [...missingParameters, ...missingBodyFields] + }) + + expect(missingInputDescriptions).toEqual([]) + }) + test("every /v1/channels/api/* operation requires only the channel token scheme", () => { const channelOps = operations.filter((op) => op.path.startsWith("/v1/channels/api/"), diff --git a/apps/builder/src/features/ads-campaign/api/public.ts b/apps/builder/src/features/ads-campaign/api/public.ts index e1e92d8ee8..fe172017a7 100644 --- a/apps/builder/src/features/ads-campaign/api/public.ts +++ b/apps/builder/src/features/ads-campaign/api/public.ts @@ -94,8 +94,7 @@ export const adsCampaignPublicRouter = { .route({ method: "POST", path: "/v1/ads/campaigns", - summary: - "Create a messaging ad (campaign + ad set + creative + ad, all PAUSED). Created without a `createdBy` — workspace API tokens have no associated user.", + summary: "Create a messaging ad", successStatus: 201, tags: ["Ads"], }) @@ -131,8 +130,7 @@ export const adsCampaignPublicRouter = { .route({ method: "POST", path: "/v1/ads/campaigns/{operationId}/retry", - summary: - "Resume a partially-created messaging ad using the same operationId", + summary: "Resume messaging ad creation", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -150,8 +148,7 @@ export const adsCampaignPublicRouter = { .route({ method: "POST", path: "/v1/ads/campaigns/{operationId}/publish", - summary: - "Publish a messaging ad — sets campaign/ad set/ad to ACTIVE on Meta. This spends real ad budget.", + summary: "Publish a messaging ad", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -205,8 +202,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns", - summary: - "List messaging ads created from ChatbotX for one channel integration, with Meta's live effective_status", + summary: "List messaging ads", tags: ["Ads"], }) .input(listMessagingAdsPublicRequest) @@ -232,8 +228,7 @@ export const adsCampaignPublicRouter = { // the same constant (`../lib/api-paths`), so they cannot drift. method: "POST", path: ADS_CAMPAIGNS_INSIGHTS_PATH, - summary: - "Ads Insights for a set of messaging ads (impressions/reach/spend/clicks/messaging conversations started/cost-per-conversation)", + summary: "Get messaging ad insights", tags: ["Ads"], }) .input(messagingAdsInsightsPublicRequest) @@ -257,8 +252,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns/{channel}/{integrationId}/ad-accounts", - summary: - "List ad accounts reachable by one integration's messaging-ads connection (cached)", + summary: "List integration ad accounts", tags: ["Ads"], }) .input(listAdAccountsPublicRequestParams.and(listAdAccountsPublicRequest)) @@ -276,8 +270,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns/ad-accounts/{adAccountId}", - summary: - "Get an ad account's currency/timezone/status/minimum budget (cached)", + summary: "Get ad account details", tags: ["Ads"], }) .input( @@ -297,8 +290,7 @@ export const adsCampaignPublicRouter = { .route({ method: "POST", path: "/v1/ads/campaigns/upload-video", - summary: - "Upload a creative video to Meta — returns the video_id (processing is async, poll getCampaignVideoStatus)", + summary: "Upload campaign video", tags: ["Ads"], }) .input(uploadAdVideoPublicRequest) @@ -324,8 +316,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns/videos/{videoId}/status", - summary: - "Poll a video's processing status — a creative must not reference a not-yet-ready video", + summary: "Get campaign video status", tags: ["Ads"], }) .input(videoStatusPublicRequestParams.and(videoStatusPublicRequest)) @@ -353,8 +344,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns/messenger-pages", - summary: - "List connected Messenger Pages (source of page_id for the WhatsApp ad-set step) — CTWA only", + summary: "List Messenger pages", tags: ["Ads"], }) .input(listMessengerPagesPublicRequest) @@ -385,8 +375,7 @@ export const adsCampaignPublicRouter = { .route({ method: "GET", path: "/v1/ads/campaigns/prerequisites", - summary: - "Whether this channel integration's messaging-ads connection is ready", + summary: "Check messaging ads prerequisites", tags: ["Ads"], }) .input(checkPrerequisitesPublicRequest) @@ -436,8 +425,7 @@ export const adsCampaignPublicRouter = { .route({ method: "DELETE", path: "/v1/ads/connections/{channel}/{integrationId}", - summary: - "Disconnect a channel integration's messaging-ads connection — best-effort revokes the Graph token first", + summary: "Disconnect messaging ads connection", successStatus: 204, tags: ["Ads"], }) diff --git a/apps/builder/src/features/ads/api/public.ts b/apps/builder/src/features/ads/api/public.ts index 3a628aab50..3f3e6b04e4 100644 --- a/apps/builder/src/features/ads/api/public.ts +++ b/apps/builder/src/features/ads/api/public.ts @@ -161,8 +161,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/funnel", - summary: - "Get the CTWA/CTM/CTID conversion funnel (conversations/leads/purchases/revenue) per ad", + summary: "Get ad conversion funnel", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -179,7 +178,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/funnel/timeseries", - summary: "Get the CTWA/CTM/CTID conversion funnel, bucketed per day", + summary: "Get daily ad conversion funnel", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -196,8 +195,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/capi-delivery", - summary: - "Get the Conversions API delivery status breakdown (sent/pending/failed/skipped)", + summary: "Get Conversions API delivery", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -214,8 +212,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/conversions/export", - summary: - "Cursor-paginated conversion/lead/purchase rows for export (contact-level — a workspace token sees unmasked contact data, see docs/developer/workspace-api-tokens.md)", + summary: "Export conversion rows", tags: ["Ads"], }) .input(listAdsConversionExportRowsPublicRequest) @@ -246,8 +243,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/{channel}/ad-accounts", - summary: - "List ad accounts for a channel — the union of every connected integration's messaging-ads connection plus the workspace-wide fallback (deduped), or one integration's own connection when integrationId is given", + summary: "List channel ad accounts", tags: ["Ads"], }) .input( @@ -272,8 +268,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/analytics/overview", - summary: - "Merged ads analytics (funnel + Meta spend/ROAS/CPM) per ad, for one channel/account/date range. Fans out to Meta Graph and requires a connected ads account — for a free, DB-only funnel, see GET /v1/ads/funnel.", + summary: "Get ad analytics overview", tags: ["Ads"], }) .input(adsAnalyticsPublicRequest) @@ -290,8 +285,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/analytics/timeseries", - summary: - "Merged ads analytics (funnel + Meta spend), bucketed per day. Fans out to Meta Graph and requires a connected ads account — for a free, DB-only funnel, see GET /v1/ads/funnel/timeseries.", + summary: "Get daily ad analytics", tags: ["Ads"], }) .input(adsAnalyticsPublicRequest) @@ -325,8 +319,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "GET", path: "/v1/ads/custom-audiences", - summary: - "List Facebook custom audiences for an ad account — makes startRetargetAudienceSync's customAudienceId discoverable", + summary: "List custom audiences", tags: ["Ads"], }) .input(listCustomAudiencesPublicRequest) @@ -348,8 +341,7 @@ const adsAnalyticsPublicRouter = { .route({ method: "POST", path: "/v1/ads/retarget-audiences", - summary: - "Sync a Facebook custom audience with contacts matching a CTWA retarget segment — runs in the worker", + summary: "Sync retarget audience", successStatus: 202, tags: ["Ads"], }) diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 9f72493a70..92beb4f6af 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -24,7 +24,8 @@ export const aiAgentsPublicRouter = { method: "GET", path: "/v1/ai-agents", summary: "List AI agents", - description: "Lists AI agents configured in the workspace.", + description: + "Use this to resolve an AI agent before referencing it in `flows.publish` or changing it with `aiAgents.update`. Returns the configured agents in the workspace.", tags: ["AI Agents"], spec: mcpSpec({ visibility: "default" }), }) @@ -65,7 +66,8 @@ export const aiAgentsPublicRouter = { method: "POST", path: "/v1/ai-agents", summary: "Create an AI agent", - description: "Creates a new AI agent in the workspace.", + description: + "Adds a configured AI agent to the workspace. Use `aiAgents.list` first to avoid duplicating an existing agent, then use `aiAgents.update` to refine its settings.", successStatus: 201, tags: ["AI Agents"], spec: mcpSpec({ visibility: "default" }), @@ -83,7 +85,8 @@ export const aiAgentsPublicRouter = { method: "PUT", path: "/v1/ai-agents/{id}", summary: "Update an AI agent", - description: "Partially updates an existing AI agent.", + description: + "Changes settings on an existing AI agent without replacing unrelated fields. Call `aiAgents.list` to resolve its id, and use `aiAgents.get` to inspect the saved result.", tags: ["AI Agents"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/ai-files/api/public.ts b/apps/builder/src/features/ai-files/api/public.ts index 46af4957f7..b2b27afc7b 100644 --- a/apps/builder/src/features/ai-files/api/public.ts +++ b/apps/builder/src/features/ai-files/api/public.ts @@ -24,7 +24,8 @@ export const aiFilesPublicRouter = { method: "GET", path: "/v1/ai-files", summary: "List AI files", - description: "Lists files uploaded to the workspace's AI knowledge base.", + description: + "Use this to find AI knowledge-base files before selecting one with `aiFiles.get` or creating another with `aiFiles.create`. Returns the uploaded files available in this workspace.", tags: ["AI Files"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/ai-functions/api/public.ts b/apps/builder/src/features/ai-functions/api/public.ts index dd97175019..5dd6bbf5ca 100644 --- a/apps/builder/src/features/ai-functions/api/public.ts +++ b/apps/builder/src/features/ai-functions/api/public.ts @@ -26,7 +26,8 @@ export const aiFunctionsPublicRouter = { method: "GET", path: "/v1/ai-functions", summary: "List AI functions", - description: "Lists AI functions (tools) configured in the workspace.", + description: + "Use this to resolve configured AI functions before inspecting one with `aiFunctions.get` or adding one with `aiFunctions.create`. Returns the functions available in this workspace.", tags: ["AI Functions"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/analytics/api/public.ts b/apps/builder/src/features/analytics/api/public.ts index accebf5267..8730fb52a9 100644 --- a/apps/builder/src/features/analytics/api/public.ts +++ b/apps/builder/src/features/analytics/api/public.ts @@ -110,7 +110,7 @@ export const analyticsPublicRouter = { path: "/v1/analytics/new-contact-counts-per-day", summary: "Get new contact counts per day", description: - "Returns the count of contacts first created on each day within the given `from`/`to` time range.", + "Use this to chart newly created contacts over a requested time range. Compare it with `analytics.blockedContactsPerDay` to separate acquisition trends from blocked contacts.", tags: ["Analytics"], spec: mcpSpec({ visibility: "default" }), }) @@ -131,7 +131,7 @@ export const analyticsPublicRouter = { path: "/v1/analytics/blocked-contacts-per-day", summary: "Get blocked contacts per day", description: - "Returns the count of contacts blocked on each day within the given `from`/`to` time range.", + "Use this to chart contacts blocked during a requested time range. Compare it with `analytics.newContactCountsPerDay` to distinguish blocking trends from new contacts.", tags: ["Analytics"], spec: mcpSpec({ visibility: "default" }), }) @@ -547,7 +547,7 @@ export const analyticsPublicRouter = { path: "/v1/analytics/broadcasts/{broadcastId}/stats", summary: "Get broadcast stats", description: - "Returns delivery stats (sent/delivered/read/failed counts) for a single broadcast.", + "Use this after resolving a broadcast with `broadcasts.get` to inspect sent, delivered, read, and failed counts. Compare results with `analytics.flowStats` for automation performance.", tags: ["Analytics"], spec: mcpSpec({ visibility: "default" }), }) @@ -572,7 +572,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/sequences/{sequenceId}/steps/{stepId}/stats", summary: "Get sequence step stats", - description: "Returns delivery stats for a single sequence step.", + description: + "Use this after resolving a sequence and step to inspect delivery counts for that step. Call `sequences.get` first for step ids, or use `analytics.broadcastStats` for broadcast delivery.", tags: ["Analytics"], spec: mcpSpec({ visibility: "default" }), }) @@ -615,7 +616,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/flows/{flowId}", summary: "Get flow analytics", - description: "Returns session/completion stats for a single flow.", + description: + "Use this after resolving a flow with `flows.get` to inspect its session and completion counts. Call `analytics.newContactCountsPerDay` instead for workspace contact trends.", tags: ["Analytics"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/appointments/api/public.ts b/apps/builder/src/features/appointments/api/public.ts index 5c178021e9..6438410773 100644 --- a/apps/builder/src/features/appointments/api/public.ts +++ b/apps/builder/src/features/appointments/api/public.ts @@ -28,7 +28,7 @@ export const appointmentsPublicRouter = { path: "/v1/appointments", summary: "List appointments", description: - "Lists appointments in the workspace, optionally filtered by calendar and tab (next/past).", + "Use this to find appointments by calendar or next/past tab before opening one with `appointments.get`. Returns paginated workspace appointments for scheduling workflows.", tags, }) .input(listAppointmentsPublicRequest) diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index 89f8d673f4..ab7ea3adc3 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -23,7 +23,7 @@ export const keywordsPublicRouter = { path: "/v1/keywords", summary: "List keywords (automated responses)", description: - "Lists keyword-triggered automated responses in the workspace, filterable by `type` (inbound/comment).", + "Use this to find keyword-triggered automations by type before inspecting one with `keywords.get` or adding one with `keywords.create`. Returns inbound or comment automations.", tags: ["Keywords"], spec: mcpSpec({ visibility: "default" }), }) @@ -76,7 +76,7 @@ export const keywordsPublicRouter = { path: "/v1/keywords", summary: "Create a keyword automation", description: - "Creates a keyword automation that replies with text or starts a flow when any of `keywords` is matched in an inbound message or comment.", + "Adds a keyword automation that sends text or starts a flow for matching inbound messages or comments. Use `keywords.list` first to inspect existing rules and `flows.list` to resolve a flow.", successStatus: 201, tags: ["Keywords"], }) diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index f5197f69ef..193b2d64a0 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -57,7 +57,7 @@ export const broadcastsPublicRouter = { path: "/v1/broadcasts", summary: "Get all broadcasts", description: - "Lists broadcasts in the workspace across every status (draft, scheduled, sending, sent, cancelled), newest first.", + "Use this to find broadcasts by status before inspecting one with `broadcasts.get` or stopping one with `broadcasts.stop`. Returns newest broadcasts across every status.", tags: ["Broadcasts"], spec: mcpSpec({ visibility: "default" }), }) @@ -79,7 +79,8 @@ export const broadcastsPublicRouter = { method: "GET", path: "/v1/broadcasts/{idOrName}", summary: "Get broadcast by id or name", - description: "Returns a single broadcast looked up by id or name.", + description: + "Use this to inspect a broadcast by id or name after finding it with `broadcasts.list`. Call `broadcasts.schedule` for a draft or `broadcasts.stop` for a sending broadcast.", tags: ["Broadcasts"], spec: mcpSpec({ visibility: "default" }), }) @@ -155,7 +156,7 @@ export const broadcastsPublicRouter = { path: "/v1/broadcasts", summary: "Create a broadcast", description: - "Creates a broadcast as a draft (or immediately scheduled, depending on the payload) targeting the given audience filter.", + "Starts a broadcast as a draft or scheduled send for the supplied audience. Use `broadcasts.list` to avoid duplicates, then use `broadcasts.schedule` to control its send time.", successStatus: 201, tags: ["Broadcasts"], }) @@ -199,7 +200,7 @@ export const broadcastsPublicRouter = { path: "/v1/broadcasts/{id}/draft", summary: "Replace a draft broadcast's full payload", description: - "Only matches a broadcast whose status is draft. Setting saveAsDraft to false schedules it.", + "Replaces a draft's complete payload and can schedule it when `saveAsDraft` is false. Call `broadcasts.get` to inspect the draft first, or use `broadcasts.schedule` to keep its payload.", tags: ["Broadcasts"], }) .input(createBroadcastRequest.and(z.object({ id: zodBigintAsString() }))) @@ -224,7 +225,8 @@ export const broadcastsPublicRouter = { method: "POST", path: "/v1/broadcasts/{id}/schedule", summary: "Schedule a draft broadcast", - description: "Only matches a broadcast whose status is draft.", + description: + "Moves a draft broadcast to its scheduled state using the provided schedule. Call `broadcasts.get` to inspect it first, or use `broadcasts.updateDraft` to change its payload.", tags: ["Broadcasts"], }) .input(scheduleBroadcastSchema.and(z.object({ id: zodBigintAsString() }))) @@ -264,7 +266,8 @@ export const broadcastsPublicRouter = { method: "POST", path: "/v1/broadcasts/{id}/stop", summary: "Stop a broadcast that is currently sending", - description: "Only matches a broadcast whose status is sending.", + description: + "Stops a broadcast only while it is sending and returns its id. Call `broadcasts.get` to confirm its state first, or use `broadcasts.moveToDraft` for scheduled broadcasts.", tags: ["Broadcasts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/capabilities/api/public.ts b/apps/builder/src/features/capabilities/api/public.ts index c494788f72..1c634762ec 100644 --- a/apps/builder/src/features/capabilities/api/public.ts +++ b/apps/builder/src/features/capabilities/api/public.ts @@ -43,8 +43,7 @@ export const capabilitiesPublicRouter = { .route({ method: "GET", path: "/v1/capabilities", - summary: - "Discover the workspace's inboxes, templates, fields, tags, sequences, and flows", + summary: "Discover workspace capabilities", description: "Returns compact (id + name, plus a couple of decisive fields) lists of the workspace entities an agent needs to reference by id — inboxes, WhatsApp templates, custom/bot fields, tags, AI agents, sequences, and flows — plus the flow-spec DSL's step types and valid wait units/channels. Bot fields are reference data only; they cannot be used as a branch condition's `field` (only custom fields and built-in contact fields can). Use `include` (comma-separated) to narrow the response; omit it for the default set an agent needs to build a flow. Call this before `flows.publish`/`flows.updateDraft`/`flows.validate` so names in a flow spec resolve to real ids instead of guesses.", tags: ["Capabilities"], diff --git a/apps/builder/src/features/contact-sequences/api/public.ts b/apps/builder/src/features/contact-sequences/api/public.ts index 76ba344253..04b526e661 100644 --- a/apps/builder/src/features/contact-sequences/api/public.ts +++ b/apps/builder/src/features/contact-sequences/api/public.ts @@ -23,7 +23,7 @@ export const contactsSequencesPublicRouter = { path: "/v1/contacts/{identifier}/sequences", summary: "List sequences the contact is enrolled in", description: - "Lists the sequences the contact identified by `identifier` is currently enrolled in.", + "Use this to inspect a contact's current sequence enrollments after resolving the contact with `contacts.get`. Call `contacts.subscribeSequences` to enroll it, or `sequences.get` to inspect a sequence.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/contacts/api/public/crud.ts b/apps/builder/src/features/contacts/api/public/crud.ts index 401e124f54..78a6fb5a59 100644 --- a/apps/builder/src/features/contacts/api/public/crud.ts +++ b/apps/builder/src/features/contacts/api/public/crud.ts @@ -39,7 +39,7 @@ export const contactsCrudPublicRouter = { path: "/v1/contacts", summary: "List contacts", description: - "List contacts in the workspace, with optional keyword search and filter. Supports `include` to shrink the response (e.g. `include=tags`) and `withCount=false` to skip the total-count query when you only need the rows.", + "Use this to find contacts by keyword or filter before inspecting one with `contacts.get` or sending a message with `contacts.sendMessage`. Supports `include` and `withCount` to shape the response.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) @@ -63,7 +63,7 @@ export const contactsCrudPublicRouter = { path: "/v1/contacts/search", summary: "Search contacts with a filter body", description: - "Same as `GET /v1/contacts` but accepts the filter as a JSON request body instead of query parameters — use this when `contactFilter` is large or deeply nested. Supports the same `include`/`withCount` options.", + "Use this when a large or nested `contactFilter` cannot fit conveniently in query parameters. It returns the same contact data as `contacts.list`, including `include` and `withCount` options.", tags: ["Contacts"], // A POST that reads, not writes — `readOnlyHint: true` keeps it // visible to a `read_only` token (`isVisibleForScope` in @@ -110,10 +110,9 @@ export const contactsCrudPublicRouter = { .route({ method: "GET", path: "/v1/contacts/{identifier}", - summary: - "Get contact by identifier (id:123, email:user@example.com, phone:+84...)", + summary: "Get a contact by identifier", description: - "Looks up a single contact by a prefixed identifier: `id:`, `email:
`, or `phone:`.", + "Use this after locating a prefixed id, email, or phone identifier to inspect one contact. Call `contacts.list` to search first, or use `contacts.sendMessage` to contact the result.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) @@ -137,7 +136,7 @@ export const contactsCrudPublicRouter = { path: "/v1/contacts", summary: "Create a contact", description: - "Creates a new contact directly in the workspace (not via a channel conversation). At least one of email or phoneNumber is typically required for later messaging.", + "Adds a workspace contact outside a channel conversation, with contact details for later messaging. Use `contacts.list` to check for an existing contact and `contacts.sendMessage` after creating one.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/contacts/api/public/custom-fields.ts b/apps/builder/src/features/contacts/api/public/custom-fields.ts index b9b358c066..a4ab284280 100644 --- a/apps/builder/src/features/contacts/api/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/api/public/custom-fields.ts @@ -33,7 +33,7 @@ export const contactsCustomFieldsPublicRouter = { path: "/v1/contacts/{identifier}/custom-fields", summary: "Get all custom fields from a contact", description: - "Lists every custom field value set on the contact identified by `identifier`.", + "Use this to inspect every custom-field value for a contact after resolving its identifier with `contacts.get`. Call `contacts.setCustomField` to change one value or `contacts.setCustomFields` to change several.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) @@ -84,7 +84,7 @@ export const contactsCustomFieldsPublicRouter = { path: "/v1/contacts/{identifier}/custom-fields/{customFieldId}", summary: "Set contact custom field value", description: - "Sets a single custom field's value on the contact identified by `identifier`. Use `contacts.setCustomFields` to set several at once.", + "Changes one custom-field value on a resolved contact without altering its other fields. Use `contacts.listCustomFields` to inspect current values, or `contacts.setCustomFields` for several changes.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/contacts/api/public/messages.ts b/apps/builder/src/features/contacts/api/public/messages.ts index fe1cb6925f..420c3d690b 100644 --- a/apps/builder/src/features/contacts/api/public/messages.ts +++ b/apps/builder/src/features/contacts/api/public/messages.ts @@ -32,7 +32,7 @@ export const contactsMessagesPublicRouter = { path: "/v1/contacts/{identifier}/messages", summary: "Send message to contact", description: - "Sends a text/media message to the contact identified by `identifier` on their existing conversation, creating one if none exists yet. Requires the contact to have an inbox they can be reached on (see `inboxes.list`).", + "Delivers a text or media message to a contact's conversation, creating one when needed. Use `contacts.get` to confirm the recipient first, and `contacts.listMessages` to inspect the conversation afterward.", successStatus: 204, tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), @@ -70,7 +70,7 @@ export const contactsMessagesPublicRouter = { path: "/v1/contacts/{identifier}/messages", summary: "List messages for contact", description: - "Lists messages on the contact's existing conversation, newest-related pagination via `cursor`.", + "Use this to inspect cursor-paginated messages from a contact's existing conversation. Call `contacts.get` to resolve the contact first, or use `contacts.sendMessage` to add an outbound message.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) @@ -190,7 +190,7 @@ export const contactsMessagesPublicRouter = { path: "/v1/contacts/{identifier}/flows", summary: "Send flow to contact", description: - "Starts the given flow for the contact identified by `identifier`, delivering its first message on their existing (or newly created) conversation.", + "Starts a flow for a resolved contact and delivers its first message on an existing or new conversation. Call `flows.list` to find the flow first, or use `contacts.sendMessage` for one message.", successStatus: 204, tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), diff --git a/apps/builder/src/features/contacts/api/public/tags.ts b/apps/builder/src/features/contacts/api/public/tags.ts index b00a64d536..6ca8ac8cae 100644 --- a/apps/builder/src/features/contacts/api/public/tags.ts +++ b/apps/builder/src/features/contacts/api/public/tags.ts @@ -24,7 +24,7 @@ export const contactsTagsPublicRouter = { path: "/v1/contacts/{identifier}/tags", summary: "Get all tags added to this contact", description: - "Lists every tag attached to the contact identified by `identifier`.", + "Use this to inspect tags attached to a contact after resolving its identifier with `contacts.get`. Call `contacts.addTags` to attach more tags or `tags.list` to discover available tags.", tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/conversations/api/public.ts b/apps/builder/src/features/conversations/api/public.ts index acb5dde5dd..a50ea57c9b 100644 --- a/apps/builder/src/features/conversations/api/public.ts +++ b/apps/builder/src/features/conversations/api/public.ts @@ -75,7 +75,7 @@ export const conversationsPublicRouter = { path: "/v1/conversations", summary: "List conversations", description: - "Lists conversations in the workspace with optional filters (status, channel, assignee, tags, contact filter). Use `conversations.get` for the full detail of one.", + "Use this to find conversations by status, channel, assignee, tags, or contact filter before inspecting one with `conversations.get`. Returns cursor-paginated workspace conversations.", tags: ["Conversations"], spec: mcpSpec({ visibility: "default" }), }) @@ -101,7 +101,7 @@ export const conversationsPublicRouter = { path: "/v1/conversations/{id}", summary: "Get a conversation by id", description: - "Returns the full detail of a single conversation, including its contact, channel, assignee, and status.", + "Use this to inspect one conversation's contact, channel, assignee, and status after locating it with `conversations.list`. Call `conversations.assign` to change its assignee.", tags: ["Conversations"], spec: mcpSpec({ visibility: "default" }), }) @@ -127,7 +127,7 @@ export const conversationsPublicRouter = { path: "/v1/conversations/{id}/assign", summary: "Assign or unassign a conversation to a user or inbox team", description: - "Sets the conversation's assignee. Pass a user id, an inbox team id, or `null`/omit `assignedId` to unassign.", + "Changes a conversation's user or inbox-team assignee, or clears it when `assignedId` is null. Call `conversations.get` first to inspect the current assignee and `conversations.list` to find the id.", tags: ["Conversations"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/coupons/api/public.ts b/apps/builder/src/features/coupons/api/public.ts index 464ae38007..b090b8f36b 100644 --- a/apps/builder/src/features/coupons/api/public.ts +++ b/apps/builder/src/features/coupons/api/public.ts @@ -74,7 +74,7 @@ export const couponsPublicRouter = { path: "/v1/coupon-topics", summary: "Create a coupon topic", description: - "Creates a coupon topic. The topic is created without a `createdById` — workspace API tokens have no associated user.", + "Adds a coupon topic without `createdById` because workspace API tokens have no user. Use `coupons.listTopics` to inspect existing topics before creating another.", tags, }) .input(createCouponTopicPublicRequest) diff --git a/apps/builder/src/features/error-logs/api/public.ts b/apps/builder/src/features/error-logs/api/public.ts index db64e906e5..9aad2985c2 100644 --- a/apps/builder/src/features/error-logs/api/public.ts +++ b/apps/builder/src/features/error-logs/api/public.ts @@ -17,7 +17,7 @@ export const errorLogsPublicRouter = { path: "/v1/error-logs", summary: "List error logs", description: - "Lists error logs recorded in the workspace, filterable by `keyword`, newest first.", + "Use this to inspect recent workspace failures by `keyword` before retrying the related action. Returns newest error logs first; use `token.get` to confirm the token can access diagnostics.", tags: ["Error Logs"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index 0d11bab986..ff784e1d26 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -40,7 +40,8 @@ export const flowsPublicRouter = { method: "GET", path: "/v1/flows", summary: "List flows", - description: "Lists active flows in the workspace.", + description: + "Use this to find flow ids and names before fetching one with `flows.get` or publishing a draft with `flows.publish`. Returns active flows in the workspace.", tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) @@ -67,7 +68,8 @@ export const flowsPublicRouter = { method: "GET", path: "/v1/flows/{id}", summary: "Get a flow by id", - description: "Returns a flow with its list of versions.", + description: + "Use this to inspect one flow and its versions after finding its id with `flows.list`. Call `flows.updateDraft` to change the draft or `flows.publish` to create a version.", tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) @@ -88,7 +90,7 @@ export const flowsPublicRouter = { path: "/v1/flows", summary: "Create a flow", description: - "Creates a new draft flow seeded with a single default start node.", + "Starts a draft flow with its default start node. Use `flows.list` to inspect existing flows first, then call `flows.updateDraft` or `flows.publish` to complete it.", successStatus: 201, tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), @@ -142,7 +144,8 @@ export const flowsPublicRouter = { method: "POST", path: "/v1/flows/{id}/duplicate", summary: "Duplicate a flow", - description: "Duplicates a flow's draft version into a new flow.", + description: + "Copies a flow's draft into a new flow. Use `flows.get` to inspect the source first, then call `flows.updateDraft` or `flows.publish` on the returned flow.", successStatus: 201, tags: ["Flows"], }) @@ -163,7 +166,7 @@ export const flowsPublicRouter = { path: "/v1/flows/{id}/publish", summary: "Publish a flow", description: - "Publishes a new immutable version and syncs the draft to match. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` — a flow-spec DSL object (see `GET /v1/schemas/flow-spec`) compiled server-side into that same graph before publishing.", + "Creates an immutable version from a draft and synchronizes the draft to match. Call `flows.validate` before this when supplying a spec, or use `flows.updateDraft` to save changes without publishing.", tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) @@ -192,6 +195,7 @@ export const flowsPublicRouter = { description: "Compiles a flow-spec DSL object (see `GET /v1/schemas/flow-spec`) and validates the result exactly like `flows.publish` would, without persisting anything. On success, returns the compiled node/edge graph. On failure, returns a 422 with structured errors (`path`/`code`/`message`/`hint`/`candidates`) — fix and retry before calling `flows.publish`.", tags: ["Flows"], + spec: mcpSpec({ visibility: "default" }), }) .input(flowSpecRequest) .output(publishFlowSchema) diff --git a/apps/builder/src/features/media-library/api/public.ts b/apps/builder/src/features/media-library/api/public.ts index bb193fc2cc..3b1486b338 100644 --- a/apps/builder/src/features/media-library/api/public.ts +++ b/apps/builder/src/features/media-library/api/public.ts @@ -227,8 +227,7 @@ export const mediaLibraryPublicRouter = { .route({ method: "POST", path: "/v1/media-library/files/{fileId}/access", - summary: - "Record that a media library file was used (feeds the recent filter)", + summary: "Record media file access", successStatus: 204, tags, }) diff --git a/apps/builder/src/features/product-categories/api/public.ts b/apps/builder/src/features/product-categories/api/public.ts index d04bdfddde..bb9fb893a5 100644 --- a/apps/builder/src/features/product-categories/api/public.ts +++ b/apps/builder/src/features/product-categories/api/public.ts @@ -24,7 +24,7 @@ export const productCategoriesPublicRouter = { path: "/v1/product-categories", summary: "List product categories", description: - "Lists product categories as a flat two-level list. `parentId` is null for a top-level category, or the id of its top-level parent for a sub-category.", + "Use this to inspect the flat two-level category tree before creating one with `productCategories.create`. A null `parentId` identifies a top-level category.", tags: ["Product Categories"], }) .output(listProductCategoriesPublicResponse) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 56d2100215..3c6c67cecd 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -32,7 +32,7 @@ export const sequencesPublicRouter = { path: "/v1/sequences", summary: "List sequences", description: - "Lists sequences in the workspace. Use `sequences.get`/`contacts.subscribeSequences` next to inspect steps or enroll a contact.", + "Use this to find sequence ids before inspecting steps with `sequences.get` or enrolling contacts with `contacts.subscribeSequences`. Returns sequences available in the workspace.", tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) @@ -52,7 +52,8 @@ export const sequencesPublicRouter = { method: "GET", path: "/v1/sequences/{id}", summary: "Get sequence details", - description: "Returns a sequence with its list of steps.", + description: + "Use this to inspect one sequence and its steps after finding its id with `sequences.list`. Call `sequences.update` to change its settings or `sequences.upsertStep` to edit steps.", tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) @@ -93,7 +94,8 @@ export const sequencesPublicRouter = { method: "PATCH", path: "/v1/sequences/{id}", summary: "Update a sequence's name or active state", - description: "Partially updates a sequence's name or active flag.", + description: + "Changes a sequence name or active state without replacing its steps. Call `sequences.get` to inspect the current sequence, or use `sequences.list` to resolve its id.", tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/tags/api/public.ts b/apps/builder/src/features/tags/api/public.ts index a693b8e34c..95e1e4ae2f 100644 --- a/apps/builder/src/features/tags/api/public.ts +++ b/apps/builder/src/features/tags/api/public.ts @@ -43,7 +43,8 @@ export const tagsPublicRouter = { method: "POST", path: "/v1/tags", summary: "Create a new tag", - description: "Creates a new tag in the workspace, returned with its id.", + description: + "Adds a workspace tag and returns its id for later attachment to contacts. Use `tags.list` to check for an existing tag, or `contacts.addTags` to attach it.", successStatus: 201, tags: ["Tags"], }) diff --git a/apps/builder/src/features/token/api/public.ts b/apps/builder/src/features/token/api/public.ts index fb880b4c96..5433ec83be 100644 --- a/apps/builder/src/features/token/api/public.ts +++ b/apps/builder/src/features/token/api/public.ts @@ -32,7 +32,7 @@ export const tokenPublicRouter = { path: "/v1/token", summary: "Get the calling token's workspace id, permission, and scopes", description: - "Returns the workspace id, permission (`read_only`/`full`), and scopes of the token making this request. `scopes: null` means unrestricted (every scope). Check this before attempting a write to see whether the token is allowed to make it.", + "Reports the calling token's workspace id, permission, and scopes, including whether `scopes: null` grants unrestricted access. Call `capabilities.get` to discover useful resources before using the token with another operation.", tags: ["Capabilities"], spec: mcpSpec({ visibility: "default", alwaysVisible: true }), }) diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index 8433174402..d697e1cc07 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -39,7 +39,8 @@ export const triggersPublicRouter = { method: "GET", path: "/v1/triggers", summary: "List triggers", - description: "Lists triggers with their real conditions and actions.", + description: + "Use this to inspect triggers and their active conditions and actions before changing one with `triggers.update`. Returns the configured triggers in this workspace.", tags: ["Triggers"], }) .input(publicListRequest) diff --git a/apps/builder/src/lib/public-api/list.ts b/apps/builder/src/lib/public-api/list.ts index 0b551b2709..205a2b5c66 100644 --- a/apps/builder/src/lib/public-api/list.ts +++ b/apps/builder/src/lib/public-api/list.ts @@ -51,10 +51,21 @@ export function withPublicPaging( > } +export const PUBLIC_LIST_PAGING_NOTE = + "Returns `{ data, pageCount }`; page with `page`/`perPage`." + +export const withListPagingNote = (description: string): string => + `${description} ${PUBLIC_LIST_PAGING_NOTE}` + export function publicListResponse(resource: T) { return z.object({ - data: z.array(resource), - pageCount: z.number().int(), + data: z.array(resource).describe("Items on this page."), + pageCount: z + .number() + .int() + .describe( + "Total number of pages for the requested perPage; stop when page >= pageCount.", + ), }) } diff --git a/apps/builder/src/lib/public-api/params.ts b/apps/builder/src/lib/public-api/params.ts new file mode 100644 index 0000000000..98e7e06ae2 --- /dev/null +++ b/apps/builder/src/lib/public-api/params.ts @@ -0,0 +1,14 @@ +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" + +export const publicIdParam = (resource: string, lookup: string) => + z.object({ + id: zodBigintAsString().describe( + `${resource} id (numeric string). Get it from \`${lookup}\`.`, + ), + }) + +export const describeId = (label: string, lookup: string) => + zodBigintAsString().describe( + `${label} id (numeric string). Get it from \`${lookup}\`.`, + ) diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 54b55ea543..fe99c918ce 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -36,7 +36,7 @@ Use `search_tools` when the task needs something outside the default set (e.g. d ## Available tools -Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). The current default set has 43 tools: +Tool names are derived from the OpenAPI `operationId` converted to `snake_case` (e.g. `tags.list` → `tags_list`). The current default set has 44 tools: ### Capabilities @@ -115,6 +115,7 @@ Tool names are derived from the OpenAPI `operationId` converted to `snake_case` | `flows_create` | Create a flow | | `flows_update_draft` | Update a flow's draft version | | `flows_publish` | Publish a flow | +| `flows_validate` | Compile and validate a flow spec without publishing it | ### Keywords diff --git a/apps/mcp-server/SKILL.md b/apps/mcp-server/SKILL.md index a2eff61a6e..f9facd3f5b 100644 --- a/apps/mcp-server/SKILL.md +++ b/apps/mcp-server/SKILL.md @@ -224,7 +224,7 @@ chatbotx error-logs list # [--page --perPage --sort ## MCP Tools (for AI agents) -Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 43 tools** — not the full ~350-operation API — plus two meta-tools that reach everything else: +Tool names are the OpenAPI `operationId` converted to `snake_case`. `tools/list` returns a curated **default set of 44 tools** — not the full ~350-operation API — plus two meta-tools that reach everything else: | Tool | Description | |---|---| @@ -248,7 +248,7 @@ Call `capabilities_get` and `token_get` first — both are always visible regard | Contacts | `contacts_create`, `contacts_get`, `contacts_list`, `contacts_search`, `contacts_list_tags`, `contacts_add_tags_by_name`, `contacts_list_custom_fields`, `contacts_set_custom_field`, `contacts_list_messages`, `contacts_send_message`, `contacts_send_flow`, `contacts_list_sequences`, `contacts_subscribe_sequences` | | Conversations | `conversations_list`, `conversations_get`, `conversations_assign` | | Error Logs | `error_logs_list` | -| Flows | `flows_list`, `flows_get`, `flows_create`, `flows_update_draft`, `flows_publish` | +| Flows | `flows_list`, `flows_get`, `flows_create`, `flows_update_draft`, `flows_publish`, `flows_validate` | | Keywords | `keywords_list` | | Messages | `messages_list` | | Sequences | `sequences_list`, `sequences_get`, `sequences_update` | From a2181b475f2227efc4e798005ed7193c1c5a9ca1 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:41:30 +0700 Subject: [PATCH 08/38] feat(api): describe contacts crud/custom-fields/messages routes and shared pagination WS1 batch 1 (partial): apps/builder/src/features/contacts/api/public/{crud,custom-fields,messages}.ts and their schema files gain description, tags, house-style summaries, and .describe() on every top-level input field. apps/builder/src/lib/pagination/index.ts describes page/perPage/sort/cursor on the shared basePaginationRequest/cursorPaginationRequest base schemas used across many public routers. --- .../src/features/contacts/api/public/crud.ts | 99 ++++++++++++++++--- .../contacts/api/public/custom-fields.ts | 83 +++++++++++++--- .../features/contacts/api/public/messages.ts | 77 ++++++++++++--- .../features/contacts/schema/public/crud.ts | 10 +- .../contacts/schema/public/custom-fields.ts | 26 ++++- .../src/features/contacts/schema/query.ts | 53 ++++++++-- apps/builder/src/lib/pagination/index.ts | 36 +++++-- 7 files changed, 325 insertions(+), 59 deletions(-) diff --git a/apps/builder/src/features/contacts/api/public/crud.ts b/apps/builder/src/features/contacts/api/public/crud.ts index 78a6fb5a59..871506061a 100644 --- a/apps/builder/src/features/contacts/api/public/crud.ts +++ b/apps/builder/src/features/contacts/api/public/crud.ts @@ -116,7 +116,16 @@ export const contactsCrudPublicRouter = { tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(contactResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -179,6 +188,8 @@ export const contactsCrudPublicRouter = { method: "POST", path: "/v1/contacts/import", summary: "Import contacts from a file", + description: + "Starts an asynchronous bulk import of contacts from a previously uploaded file (`fileId`) into the given inbox. Returns an `importId` immediately; the import itself runs in the background, so newly imported contacts may not appear in `contacts.list` right away.", successStatus: 201, tags: ["Contacts"], }) @@ -208,7 +219,14 @@ export const contactsCrudPublicRouter = { }) .input( z - .object({ identifier: z.string().min(1) }) + .object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }) .and(updateContactFieldRequest), ) .errors(possibleErrorsOnMutatingResource) @@ -229,10 +247,21 @@ export const contactsCrudPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}", summary: "Delete a contact", + description: + "Permanently deletes the contact identified by `identifier`. Use `contacts.block` instead if you only need to stop the contact from messaging in.", successStatus: 204, tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const contactId = await contactService.resolveIdByIdentifier({ @@ -251,10 +280,21 @@ export const contactsCrudPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/block", summary: "Block a contact", + description: + "Marks the contact identified by `identifier` as blocked, preventing further inbound messages from reaching the workspace. Use `contacts.unblock` to reverse this.", successStatus: 204, tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const contactId = await contactService.resolveIdByIdentifier({ @@ -272,10 +312,21 @@ export const contactsCrudPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/unblock", summary: "Unblock a contact", + description: + "Reverses `contacts.block` for the contact identified by `identifier`, allowing inbound messages again.", successStatus: 204, tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const contactId = await contactService.resolveIdByIdentifier({ @@ -299,18 +350,42 @@ export const contactsCrudPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - firstName: z.string().trim().max(100).optional(), - lastName: z.string().trim().max(100).optional(), - email: z.union([z.literal(""), z.email().max(100)]).optional(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + firstName: z + .string() + .trim() + .max(100) + .optional() + .describe("Contact's first name."), + lastName: z + .string() + .trim() + .max(100) + .optional() + .describe("Contact's last name."), + email: z + .union([z.literal(""), z.email().max(100)]) + .optional() + .describe("Contact's email address, or an empty string to clear it."), phoneNumber: z .string() .min(10) .max(20) .regex(/\+?\d{10,20}/) - .optional(), - avatar: z.string().optional(), - gender: genderTypes.optional(), + .optional() + .describe( + "Contact's phone number in E.164-like digits (10-20 digits, optional leading +).", + ), + avatar: z + .string() + .optional() + .describe("URL of the contact's avatar image."), + gender: genderTypes.optional().describe("Contact's gender."), }), ) .output(contactResponse) diff --git a/apps/builder/src/features/contacts/api/public/custom-fields.ts b/apps/builder/src/features/contacts/api/public/custom-fields.ts index a4ab284280..7fc23a21f3 100644 --- a/apps/builder/src/features/contacts/api/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/api/public/custom-fields.ts @@ -37,7 +37,16 @@ export const contactsCustomFieldsPublicRouter = { tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(listPublicContactCustomFieldsResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -56,12 +65,21 @@ export const contactsCustomFieldsPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/custom-fields/{customFieldId}", summary: "Get contact custom field value", + description: + "Returns one custom field's current value for the contact identified by `identifier`. Use `contacts.listCustomFields` to see every field at once.", tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - customFieldId: zodBigintAsString(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + customFieldId: zodBigintAsString().describe( + "Custom field id (numeric string). Get it from `customFields.list`.", + ), }), ) .output(publicContactCustomFieldResource) @@ -90,9 +108,16 @@ export const contactsCustomFieldsPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - customFieldId: zodBigintAsString(), - value: z.string().trim(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + customFieldId: zodBigintAsString().describe( + "Custom field id (numeric string). Get it from `customFields.list`.", + ), + value: z.string().trim().describe("New value for the custom field."), }), ) .errors(possibleErrorsOnMutatingResource) @@ -121,16 +146,27 @@ export const contactsCustomFieldsPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), fields: z .array( z.object({ - customFieldId: zodBigintAsString(), - value: z.string().trim(), + customFieldId: zodBigintAsString().describe( + "Custom field id (numeric string). Get it from `customFields.list`.", + ), + value: z + .string() + .trim() + .describe("New value for this custom field."), }), ) .min(1) - .max(20), + .max(20) + .describe("Custom field values to set, up to 20 per request."), }), ) .errors(possibleErrorsOnMutatingResource) @@ -181,13 +217,23 @@ export const contactsCustomFieldsPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}/custom-fields/{idOrName}", summary: "Delete contact custom field by id or name", + description: + "Removes one custom-field value from the contact identified by `identifier`, matched by id or field name. Use `contacts.clearCustomFields` to clear every field at once.", successStatus: 204, tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - idOrName: z.string().min(1), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + idOrName: z + .string() + .min(1) + .describe("Custom field id (numeric string) or exact field name."), }), ) .errors(possibleErrorsOnDeletingResource) @@ -208,10 +254,21 @@ export const contactsCustomFieldsPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}/custom-fields", summary: "Clear all custom fields from a contact", + description: + "Removes every custom-field value from the contact identified by `identifier`. Use `contacts.clearCustomField` to remove just one.", successStatus: 204, tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { const contactId = await contactService.resolveIdByIdentifier({ diff --git a/apps/builder/src/features/contacts/api/public/messages.ts b/apps/builder/src/features/contacts/api/public/messages.ts index 420c3d690b..5e08dbe41d 100644 --- a/apps/builder/src/features/contacts/api/public/messages.ts +++ b/apps/builder/src/features/contacts/api/public/messages.ts @@ -40,7 +40,12 @@ export const contactsMessagesPublicRouter = { .input( createMessageRequest.and( z.object({ - identifier: z.string().min(1), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), }), ), ) @@ -76,9 +81,23 @@ export const contactsMessagesPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - perPage: z.coerce.number().optional().default(20), - cursor: z.string().optional(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + perPage: z.coerce + .number() + .optional() + .default(20) + .describe("Number of messages per page."), + cursor: z + .string() + .optional() + .describe( + "Opaque pagination cursor from a previous response. Omit for the first page.", + ), }), ) .output(listMessagesResponse) @@ -108,12 +127,19 @@ export const contactsMessagesPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/messages/{messageId}", summary: "Get a message by ID for a contact", + description: + "Returns one message from a contact's conversation. Call `contacts.listMessages` to find its `messageId` first.", tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - messageId: zodBigintAsString(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + messageId: zodBigintAsString().describe("Message id (numeric string)."), }), ) .output(messageResourceWithRelations) @@ -142,14 +168,30 @@ export const contactsMessagesPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/auto-replies", summary: "Trigger auto reply for contact", + description: + "Simulates the contact sending `keyword` and delivers whichever automated response is configured to match it, as if it had arrived inbound. Use `contacts.sendMessage` to send arbitrary text instead.", successStatus: 204, tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - keyword: z.string().min(1), - inboxId: zodBigintAsString().optional(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + keyword: z + .string() + .min(1) + .describe( + "Inbound keyword to match against configured auto-replies.", + ), + inboxId: zodBigintAsString() + .optional() + .describe( + "Inbox id (numeric string) to send from. Get it from `inboxes.list`.", + ), }), ) .errors(possibleErrorsOnMutatingResource) @@ -197,9 +239,20 @@ export const contactsMessagesPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - flowId: zodBigintAsString(), - inboxId: zodBigintAsString().optional(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + flowId: zodBigintAsString().describe( + "Flow id (numeric string). Get it from `flows.list`.", + ), + inboxId: zodBigintAsString() + .optional() + .describe( + "Inbox id (numeric string) to send from. Get it from `inboxes.list`.", + ), }), ) .errors(possibleErrorsOnMutatingResource) diff --git a/apps/builder/src/features/contacts/schema/public/crud.ts b/apps/builder/src/features/contacts/schema/public/crud.ts index 33fa6ceeec..3a9c2b020f 100644 --- a/apps/builder/src/features/contacts/schema/public/crud.ts +++ b/apps/builder/src/features/contacts/schema/public/crud.ts @@ -37,10 +37,16 @@ export type CountContactsPublicRequest = z.infer< typeof countContactsPublicRequest > -export const countContactsPublicResponse = z.object({ total: z.number() }) +export const countContactsPublicResponse = z.object({ + total: z.number().describe("Number of contacts matching the filter."), +}) export const importContactsPublicResponse = z.object({ - importId: z.string(), + importId: z + .string() + .describe( + "Id of the background import job. The import runs asynchronously; imported contacts appear in `contacts.list` once it finishes.", + ), }) export type ImportContactsPublicResponse = z.infer< typeof importContactsPublicResponse diff --git a/apps/builder/src/features/contacts/schema/public/custom-fields.ts b/apps/builder/src/features/contacts/schema/public/custom-fields.ts index 047c920dda..b8b53b3569 100644 --- a/apps/builder/src/features/contacts/schema/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/schema/public/custom-fields.ts @@ -26,14 +26,30 @@ export const publicFieldOperationNameToCode: Record< } const contactCustomFieldOperationPublicRequest = z.object({ - customFieldId: zodBigintAsString(), - operation: publicFieldOperationNames, - value: z.string().trim(), + customFieldId: zodBigintAsString().describe( + "Custom field id (numeric string). Get it from `customFields.list`.", + ), + operation: publicFieldOperationNames.describe( + "Operation to apply. `increase`/`decrease` treat the current value as a number and are a no-op if it isn't.", + ), + value: z + .string() + .trim() + .describe("Operand: the value to set, append, prepend, or add/subtract."), }) export const addContactCustomFieldOperationsPublicRequest = z.object({ - identifier: z.string().min(1), - operations: z.array(contactCustomFieldOperationPublicRequest).min(1).max(20), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + operations: z + .array(contactCustomFieldOperationPublicRequest) + .min(1) + .max(20) + .describe("Operations to apply in order, up to 20 per request."), }) export type AddContactCustomFieldOperationsPublicRequest = z.infer< typeof addContactCustomFieldOperationsPublicRequest diff --git a/apps/builder/src/features/contacts/schema/query.ts b/apps/builder/src/features/contacts/schema/query.ts index c0dd9b0505..739d881363 100644 --- a/apps/builder/src/features/contacts/schema/query.ts +++ b/apps/builder/src/features/contacts/schema/query.ts @@ -47,17 +47,46 @@ export { } from "@/features/contact-filter/schema" export const listContactsRequest = basePaginationRequest.extend({ - keyword: z.string().optional(), + keyword: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the contact's name, email, or phone.", + ), workspaceId: zodBigintAsString(), contactFilter: z.preprocess( parseContactFilterSearchParam, - contactFilterCriteriaSchema.optional(), + contactFilterCriteriaSchema + .optional() + .describe( + "Structured filter (same shape as `contacts.filterFields`) for advanced matching beyond `keyword`. See `contactFilter.get` for the field/operator reference.", + ), ), - channels: z.array(channelTypes).optional(), - integrationWhatsappId: zodBigintAsString().optional(), - integrationMessengerId: zodBigintAsString().optional(), - inboxIds: z.array(zodBigintAsString()).optional(), - subaction: broadcastSubactions.optional(), + channels: z + .array(channelTypes) + .optional() + .describe( + "Restrict to contacts with at least one inbox on one of these channels.", + ), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe( + "Restrict to contacts reachable via this WhatsApp integration id.", + ), + integrationMessengerId: zodBigintAsString() + .optional() + .describe( + "Restrict to contacts reachable via this Messenger integration id.", + ), + inboxIds: z + .array(zodBigintAsString()) + .optional() + .describe( + "Restrict to contacts with at least one conversation in one of these inbox ids.", + ), + subaction: broadcastSubactions + .optional() + .describe("Broadcast recipient sub-action filter."), }) export type ListContactsRequest = z.infer @@ -125,8 +154,14 @@ export const findContactRequest = contactResource export type FindContactRequest = z.infer export const publicListContactsByCustomFieldRequest = z.object({ - customFieldId: z.string(), - value: z.string(), + customFieldId: z + .string() + .describe( + "Custom field id (numeric string). Get it from `customFields.list`.", + ), + value: z + .string() + .describe("Custom field value to match, exact string comparison."), }) export type PublicListContactsByCustomFieldRequest = z.infer< diff --git a/apps/builder/src/lib/pagination/index.ts b/apps/builder/src/lib/pagination/index.ts index 868dbe38c9..d3238c6cce 100644 --- a/apps/builder/src/lib/pagination/index.ts +++ b/apps/builder/src/lib/pagination/index.ts @@ -3,8 +3,18 @@ import z from "zod" const sortSchema = z.array(z.object({ id: z.string(), desc: z.boolean() })) export const basePaginationRequest = z.object({ - page: z.coerce.number().int().min(1).nullish(), - perPage: z.coerce.number().int().min(1).nullish(), + page: z.coerce + .number() + .int() + .min(1) + .nullish() + .describe("Page number, starting at 1."), + perPage: z.coerce + .number() + .int() + .min(1) + .nullish() + .describe("Number of items per page."), sort: z.preprocess((val) => { if (val === undefined) { return @@ -25,12 +35,24 @@ export const basePaginationRequest = z.object({ } catch { return } - }, sortSchema.nullish()), + }, sortSchema + .nullish() + .describe("Sort order as `[{ id, desc }]` column/direction pairs.")), }) export const cursorPaginationRequest = z.object({ - cursor: z.string().optional(), - perPage: z.coerce.number().int().min(1).nullish(), + cursor: z + .string() + .optional() + .describe( + "Opaque pagination cursor from a previous response's `nextCursor`. Omit to start from the first page.", + ), + perPage: z.coerce + .number() + .int() + .min(1) + .nullish() + .describe("Number of items per page."), sort: z.preprocess((val) => { if (val === undefined) { return @@ -51,7 +73,9 @@ export const cursorPaginationRequest = z.object({ } catch { return } - }, sortSchema.nullish()), + }, sortSchema + .nullish() + .describe("Sort order as `[{ id, desc }]` column/direction pairs.")), }) export const decodeCursor = ( From 13214c05a6ee1b3cb7fc26f1a4b27ddc99b64734 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:42:48 +0700 Subject: [PATCH 09/38] feat(api): describe contacts tags routes WS1 batch 1 (partial): apps/builder/src/features/contacts/api/public/tags.ts and its schema gain description, tags, house-style summaries, and .describe() on every top-level input field. --- .../src/features/contacts/api/public/tags.ts | 43 ++++++++++++++++--- .../features/contacts/schema/public/tags.ts | 7 ++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/builder/src/features/contacts/api/public/tags.ts b/apps/builder/src/features/contacts/api/public/tags.ts index 6ca8ac8cae..c222eed067 100644 --- a/apps/builder/src/features/contacts/api/public/tags.ts +++ b/apps/builder/src/features/contacts/api/public/tags.ts @@ -28,7 +28,16 @@ export const contactsTagsPublicRouter = { tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(z.object({ data: z.array(publicTagResource) })) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -54,8 +63,19 @@ export const contactsTagsPublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - tagIds: z.array(zodBigintAsString()).min(1).max(100), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + tagIds: z + .array(zodBigintAsString()) + .min(1) + .max(100) + .describe( + "Tag ids (numeric strings) to attach, up to 100. Get them from `tags.list`.", + ), }), ) .errors(possibleErrorsOnMutatingResource) @@ -76,13 +96,26 @@ export const contactsTagsPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}/tags", summary: "Remove tags from the contact", + description: + "Detaches the given tag ids from the contact identified by `identifier`; tags not currently on the contact are ignored. Use `contacts.listTags` to see current tags first.", successStatus: 204, tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - tagIds: z.array(zodBigintAsString()).min(1).max(100), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + tagIds: z + .array(zodBigintAsString()) + .min(1) + .max(100) + .describe( + "Tag ids (numeric strings) to detach, up to 100. Get them from `tags.list`.", + ), }), ) .errors(possibleErrorsOnDeletingResource) diff --git a/apps/builder/src/features/contacts/schema/public/tags.ts b/apps/builder/src/features/contacts/schema/public/tags.ts index ae62e5ff6c..525ca2fd74 100644 --- a/apps/builder/src/features/contacts/schema/public/tags.ts +++ b/apps/builder/src/features/contacts/schema/public/tags.ts @@ -4,7 +4,12 @@ const tagNamesDescription = "Tag names — not ids. Existing tags whose name matches are reused; unmatched names are created as new tags." export const setAllContactTagsPublicRequest = z.object({ - identifier: z.string().min(1), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), tags: z .array(z.string().trim().min(1)) .max(100) From 0edf6915533759a805479eeb2518c5e73e19ed9c Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:50:13 +0700 Subject: [PATCH 10/38] feat(api): complete contacts public API description coverage (WS1 batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes WS1 batch 1: describes contacts.create/update/import/sendMessage's underlying shared schemas (schema/action.ts, schema/contact-import.ts, messages/schema/mutation.ts), and adds description+tags+house-style summaries+field descriptions to the three sibling routers mounted under the contacts.* operationId namespace (contact-notes, contact-inboxes, contact-sequences). Normalizes the two remaining .meta({ description }) sites in messages/schema/{mutation,public}.ts to .describe(). Removes "contacts." from public-spec-operations.test.ts's DESCRIPTION_BACKLOG ratchet — all 47 contacts.* operations now pass every description/tag/summary-style/field-description assertion. --- .../__tests__/public-spec-operations.test.ts | 1 - .../features/contact-inboxes/api/public.ts | 13 ++- .../src/features/contact-notes/api/public.ts | 46 ++++++++-- .../features/contact-notes/schema/action.ts | 2 +- .../features/contact-sequences/api/public.ts | 40 ++++++++- .../contact-sequences/schema/public.ts | 12 ++- .../features/contacts/api/public/export.ts | 2 + .../contacts/api/public/refresh-profile.ts | 11 ++- .../src/features/contacts/schema/action.ts | 77 ++++++++++++---- .../contacts/schema/contact-import.ts | 90 +++++++++++++++---- .../features/contacts/schema/public/bulk.ts | 8 +- .../features/contacts/schema/public/export.ts | 4 +- .../builder/src/features/import/api/public.ts | 6 ++ .../src/features/import/schema/public.ts | 13 ++- .../src/features/messages/schema/mutation.ts | 86 +++++++++++++----- .../src/features/messages/schema/public.ts | 31 +++++-- 16 files changed, 356 insertions(+), 86 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index c8d19c4648..06ef9fd64b 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -72,7 +72,6 @@ const DESCRIPTION_BACKLOG = new Set([ "capabilities.", "channels.", "contactScans.", - "contacts.", "conversations.", "coupons.", "customFields.", diff --git a/apps/builder/src/features/contact-inboxes/api/public.ts b/apps/builder/src/features/contact-inboxes/api/public.ts index 8ccf219900..2212663ec3 100644 --- a/apps/builder/src/features/contact-inboxes/api/public.ts +++ b/apps/builder/src/features/contact-inboxes/api/public.ts @@ -12,9 +12,20 @@ export const contactsInboxesPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/inboxes", summary: "List the contact's channel identities (contact inboxes)", + description: + "Returns each channel-specific connection (contact inbox) this contact has, e.g. their WhatsApp phone number or Messenger PSID per inbox. Use `contacts.get` to resolve the contact first.", tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(listContactInboxesPublicResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { diff --git a/apps/builder/src/features/contact-notes/api/public.ts b/apps/builder/src/features/contact-notes/api/public.ts index a11f4af04a..0854ffe137 100644 --- a/apps/builder/src/features/contact-notes/api/public.ts +++ b/apps/builder/src/features/contact-notes/api/public.ts @@ -21,9 +21,20 @@ export const contactsNotesPublicRouter = { method: "GET", path: "/v1/contacts/{identifier}/notes", summary: "List notes on the contact", + description: + "Returns every internal note on the contact identified by `identifier`. Use `contacts.createNote` to add one.", tags: ["Contacts"], }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(listContactNotesPublicResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -44,11 +55,20 @@ export const contactsNotesPublicRouter = { method: "POST", path: "/v1/contacts/{identifier}/notes", summary: "Add a note to the contact", + description: + "Adds an internal note to the contact identified by `identifier`, visible only to workspace users. Use `contacts.listNotes` to see existing notes.", tags: ["Contacts"], }) .input( addContactNotePublicRequest.and( - z.object({ identifier: z.string().min(1) }), + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), ), ) // Mutating, not creating: the note is new, but `{identifier}` is resolved @@ -74,13 +94,20 @@ export const contactsNotesPublicRouter = { method: "PUT", path: "/v1/contacts/{identifier}/notes/{noteId}", summary: "Update a note on the contact", + description: + "Overwrites the text of one note on the contact identified by `identifier`. Use `contacts.listNotes` to find its `noteId` first.", tags: ["Contacts"], }) .input( updateContactNotePublicRequest.and( z.object({ - identifier: z.string().min(1), - noteId: zodBigintAsString(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + noteId: zodBigintAsString().describe("Note id (numeric string)."), }), ), ) @@ -104,13 +131,20 @@ export const contactsNotesPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}/notes/{noteId}", summary: "Delete a note from the contact", + description: + "Permanently removes one note from the contact identified by `identifier`.", successStatus: 204, tags: ["Contacts"], }) .input( z.object({ - identifier: z.string().min(1), - noteId: zodBigintAsString(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + noteId: zodBigintAsString().describe("Note id (numeric string)."), }), ) .errors(possibleErrorsOnDeletingResource) diff --git a/apps/builder/src/features/contact-notes/schema/action.ts b/apps/builder/src/features/contact-notes/schema/action.ts index 8172f19a50..f615e80637 100644 --- a/apps/builder/src/features/contact-notes/schema/action.ts +++ b/apps/builder/src/features/contact-notes/schema/action.ts @@ -2,7 +2,7 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const addContactNoteRequest = z.object({ - text: z.string().trim().min(1).max(1000), + text: z.string().trim().min(1).max(1000).describe("Note text."), }) export type AddContactNoteRequest = z.infer diff --git a/apps/builder/src/features/contact-sequences/api/public.ts b/apps/builder/src/features/contact-sequences/api/public.ts index 04b526e661..ae7154e680 100644 --- a/apps/builder/src/features/contact-sequences/api/public.ts +++ b/apps/builder/src/features/contact-sequences/api/public.ts @@ -27,7 +27,16 @@ export const contactsSequencesPublicRouter = { tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ identifier: z.string().min(1) })) + .input( + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), + ) .output(listContactSequencesPublicResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -56,7 +65,14 @@ export const contactsSequencesPublicRouter = { }) .input( contactSequenceIdsPublicRequest.and( - z.object({ identifier: z.string().min(1) }), + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), ), ) .errors(possibleErrorsOnMutatingResource) @@ -78,12 +94,21 @@ export const contactsSequencesPublicRouter = { method: "DELETE", path: "/v1/contacts/{identifier}/sequences", summary: "Remove the contact from one or more sequences", + description: + "Removes the contact identified by `identifier` from each given sequence; sequences it isn't enrolled in are ignored. Use `contacts.listSequences` to see current enrollments first.", successStatus: 204, tags: ["Contacts"], }) .input( contactSequenceIdsPublicRequest.and( - z.object({ identifier: z.string().min(1) }), + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), ), ) .errors(possibleErrorsOnDeletingResource) @@ -113,7 +138,14 @@ export const contactsSequencesPublicRouter = { }) .input( setContactSequencesPublicRequest.and( - z.object({ identifier: z.string().min(1) }), + z.object({ + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + }), ), ) .errors(possibleErrorsOnMutatingResource) diff --git a/apps/builder/src/features/contact-sequences/schema/public.ts b/apps/builder/src/features/contact-sequences/schema/public.ts index 7ab335e498..6012be0049 100644 --- a/apps/builder/src/features/contact-sequences/schema/public.ts +++ b/apps/builder/src/features/contact-sequences/schema/public.ts @@ -14,14 +14,22 @@ export const contactSequenceIdsPublicRequest = z.object({ sequenceIds: z .array(zodBigintAsString()) .min(1, "At least one sequence id is required") - .max(100), + .max(100) + .describe( + "Sequence ids (numeric strings), up to 100. Get them from `sequences.list`.", + ), }) export type ContactSequenceIdsPublicRequest = z.infer< typeof contactSequenceIdsPublicRequest > export const setContactSequencesPublicRequest = z.object({ - sequenceIds: z.array(zodBigintAsString()).max(100), + sequenceIds: z + .array(zodBigintAsString()) + .max(100) + .describe( + "Sequence ids (numeric strings) the contact should be enrolled in, up to 100. Get them from `sequences.list`.", + ), }) export type SetContactSequencesPublicRequest = z.infer< typeof setContactSequencesPublicRequest diff --git a/apps/builder/src/features/contacts/api/public/export.ts b/apps/builder/src/features/contacts/api/public/export.ts index 593a7e4255..0791f073ef 100644 --- a/apps/builder/src/features/contacts/api/public/export.ts +++ b/apps/builder/src/features/contacts/api/public/export.ts @@ -47,6 +47,8 @@ export const contactsExportPublicRouter = { method: "GET", path: "/v1/contacts/export-files/{fileId}", summary: "Get a contact export file's status and download URL", + description: + "Polls the status of an export started with `contacts.export`. Once `status` is complete, the response includes a download URL for the CSV file.", tags: ["Contacts"], }) .input(getExportFilePublicRequest) diff --git a/apps/builder/src/features/contacts/api/public/refresh-profile.ts b/apps/builder/src/features/contacts/api/public/refresh-profile.ts index 1a8dd85363..d0051ab2d9 100644 --- a/apps/builder/src/features/contacts/api/public/refresh-profile.ts +++ b/apps/builder/src/features/contacts/api/public/refresh-profile.ts @@ -20,8 +20,15 @@ export const contactsRefreshProfilePublicRouter = { }) .input( z.object({ - identifier: z.string().min(1), - contactInboxId: zodBigintAsString(), + identifier: z + .string() + .min(1) + .describe( + "Contact identifier: the numeric contact id, an email address, or a phone number.", + ), + contactInboxId: zodBigintAsString().describe( + "Id (numeric string) of the contact's channel inbox connection to refresh from.", + ), }), ) .output(refreshContactProfilePublicResponse) diff --git a/apps/builder/src/features/contacts/schema/action.ts b/apps/builder/src/features/contacts/schema/action.ts index 003db5fbdd..10d1eee68e 100644 --- a/apps/builder/src/features/contacts/schema/action.ts +++ b/apps/builder/src/features/contacts/schema/action.ts @@ -19,14 +19,33 @@ export const createContactRequest = z .max(20) .regex(/\+?\d{10,20}/), ]) - .optional(), - email: z.union([z.literal(""), z.email().max(100)]), - contactId: z.string().max(255).optional(), - firstName: z.optional(z.string().trim().max(100)), - lastName: z.optional(z.string().trim().max(100)), - gender: genderTypes, - channel: channelTypes, - inboxId: zodBigintAsString("Please select an inbox"), + .optional() + .describe( + "Contact's phone number. Required when `channel` is `whatsapp`.", + ), + email: z + .union([z.literal(""), z.email().max(100)]) + .describe("Contact's email address. Required when `channel` is `smtp`."), + contactId: z + .string() + .max(255) + .optional() + .describe( + "Channel-specific user id (e.g. Messenger PSID). Required for channels other than webchat/omnichannel.", + ), + firstName: z + .optional(z.string().trim().max(100)) + .describe("Contact's first name."), + lastName: z + .optional(z.string().trim().max(100)) + .describe("Contact's last name."), + gender: genderTypes.describe("Contact's gender."), + channel: channelTypes.describe( + "Channel this contact is reachable on; determines which of phoneNumber/email/contactId is required.", + ), + inboxId: zodBigintAsString("Please select an inbox").describe( + "Inbox id (numeric string) to create the contact in. Get it from `inboxes.list`.", + ), }) .superRefine((data, ctx) => { const ch = data.channel @@ -71,17 +90,43 @@ export type UpdateContactFieldRequest = z.infer< > export const exportContactsFilter = z.object({ - keyword: z.string().optional(), - contactFilter: contactFilterCriteriaSchema.optional(), + keyword: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the contact's name, email, or phone.", + ), + contactFilter: contactFilterCriteriaSchema + .optional() + .describe( + "Structured filter for advanced matching beyond keyword. See `contactFilter.get`.", + ), }) export type ExportContactsFilter = z.infer export const exportContactsRequest = z .object({ - fields: z.array(z.string()).min(1), - contactIds: z.array(zodBigintAsString()).optional(), - exportAll: z.boolean().optional(), - filter: exportContactsFilter.optional(), + fields: z + .array(z.string()) + .min(1) + .describe( + "Contact fields to include as CSV columns, e.g. `sys:firstName`, `sys:email`, or a custom field id.", + ), + contactIds: z + .array(zodBigintAsString()) + .optional() + .describe( + "Specific contact ids to export. Required unless `exportAll` is true.", + ), + exportAll: z + .boolean() + .optional() + .describe( + "Export every contact matching `filter` (or the whole workspace if omitted).", + ), + filter: exportContactsFilter + .optional() + .describe("Narrows which contacts `exportAll` exports."), }) .refine( (data) => (data.exportAll ? true : (data.contactIds?.length ?? 0) > 0), @@ -93,7 +138,9 @@ export const exportContactsRequest = z export type ExportContactsRequest = z.infer export const exportContactsResponse = z.object({ - fileId: zodBigintAsString(), + fileId: zodBigintAsString().describe( + "Export file id (numeric string). Poll `contacts.getExportFile` with it.", + ), }) export type ExportContactsResponse = z.infer diff --git a/apps/builder/src/features/contacts/schema/contact-import.ts b/apps/builder/src/features/contacts/schema/contact-import.ts index 61bf9fc6b5..add26a4503 100644 --- a/apps/builder/src/features/contacts/schema/contact-import.ts +++ b/apps/builder/src/features/contacts/schema/contact-import.ts @@ -8,24 +8,72 @@ import { z } from "zod" export const importContactsRequest = z .object({ - fileId: zodBigintAsString(), - channel: channelTypes, - inboxId: zodBigintAsString(), - timezone: z.string().trim().min(1).max(255).optional(), + fileId: zodBigintAsString().describe( + "Id (numeric string) of a previously uploaded CSV/XLSX file to import.", + ), + channel: channelTypes.describe("Channel to attach imported contacts to."), + inboxId: zodBigintAsString().describe( + "Inbox id (numeric string) to import contacts into. Get it from `inboxes.list`.", + ), + timezone: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe( + "IANA timezone applied to imported contacts, e.g. `America/New_York`.", + ), countryCode: z.preprocess( (val) => (val === "" ? undefined : val), - countryCodeSchema.optional(), + countryCodeSchema + .optional() + .describe( + "Default country code used to normalize imported phone numbers.", + ), ), - phoneNumber: z.string().max(255).optional(), - contactId: z.string().max(255).optional(), + phoneNumber: z + .string() + .max(255) + .optional() + .describe( + "Column name in the file that holds the contact's phone number.", + ), + contactId: z + .string() + .max(255) + .optional() + .describe( + "Column name in the file that holds a channel-specific user id.", + ), // Channel-agnostic column-map key mirroring `ContactInbox.sourceUserId` // (e.g. a WhatsApp Business-Scoped User ID). Only meaningful for whatsapp // imports today — see the `superRefine` rule below. - sourceUserId: z.string().max(255).optional(), - email: z.string().max(255).optional(), - firstName: z.string().max(255).optional(), - lastName: z.string().max(255).optional(), - tagId: zodBigintAsString().optional(), + sourceUserId: z + .string() + .max(255) + .optional() + .describe( + "Column name in the file that holds the WhatsApp Business-Scoped User ID. Only used for whatsapp imports.", + ), + email: z + .string() + .max(255) + .optional() + .describe("Column name in the file that holds the contact's email."), + firstName: z + .string() + .max(255) + .optional() + .describe("Column name in the file that holds the contact's first name."), + lastName: z + .string() + .max(255) + .optional() + .describe("Column name in the file that holds the contact's last name."), + tagId: zodBigintAsString() + .optional() + .describe("Tag id (numeric string) to apply to every imported contact."), fieldMapping: z.preprocess( (val) => Array.isArray(val) @@ -34,18 +82,24 @@ export const importContactsRequest = z z .array( z.object({ - column: z.string().min(1).max(255), + column: z + .string() + .min(1) + .max(255) + .describe("Column name in the file to map."), // A custom field id, or a `bot_field:` reference from the // combined picker — a bot-field mapping is applied once after // the import completes (last row wins), not per row. - customFieldId: z.union([ - zodBigintAsString(), - z.string().regex(/^bot_field:\d+$/), - ]), + customFieldId: z + .union([zodBigintAsString(), z.string().regex(/^bot_field:\d+$/)]) + .describe( + "Custom field id (numeric string), or `bot_field:` to apply a bot-field mapping once after import.", + ), }), ) .max(10) - .optional(), + .optional() + .describe("Column-to-custom-field mappings, up to 10."), ), }) .superRefine((data, ctx) => { diff --git a/apps/builder/src/features/contacts/schema/public/bulk.ts b/apps/builder/src/features/contacts/schema/public/bulk.ts index 78074e6d58..70ab6c1c37 100644 --- a/apps/builder/src/features/contacts/schema/public/bulk.ts +++ b/apps/builder/src/features/contacts/schema/public/bulk.ts @@ -27,7 +27,13 @@ export type BulkAddTagsPublicRequest = z.infer export const bulkSubscribeSequencesPublicRequest = bulkContactIdsPublicRequest.extend({ - sequenceIds: z.array(zodBigintAsString()).min(1).max(20), + sequenceIds: z + .array(zodBigintAsString()) + .min(1) + .max(20) + .describe( + "Sequence ids (numeric strings) to enroll into. Get them from `sequences.list`.", + ), }) export type BulkSubscribeSequencesPublicRequest = z.infer< typeof bulkSubscribeSequencesPublicRequest diff --git a/apps/builder/src/features/contacts/schema/public/export.ts b/apps/builder/src/features/contacts/schema/public/export.ts index e303e85b4b..60dedce75e 100644 --- a/apps/builder/src/features/contacts/schema/public/export.ts +++ b/apps/builder/src/features/contacts/schema/public/export.ts @@ -2,7 +2,9 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const getExportFilePublicRequest = z.object({ - fileId: zodBigintAsString(), + fileId: zodBigintAsString().describe( + "Export file id (numeric string) returned by `contacts.export`.", + ), }) export const getExportFilePublicResponse = z.object({ diff --git a/apps/builder/src/features/import/api/public.ts b/apps/builder/src/features/import/api/public.ts index 3bd2ced7b8..ba1bb31015 100644 --- a/apps/builder/src/features/import/api/public.ts +++ b/apps/builder/src/features/import/api/public.ts @@ -4,6 +4,7 @@ import { possibleErrorsOnFindingResource, possibleErrorsOnListingResource, } from "@/lib/orpc/orpc-error-helper" +import { withListPagingNote } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { contactImportPublicResource, @@ -20,6 +21,9 @@ export const importPublicRouter = { method: "GET", path: "/v1/contacts/imports", summary: "List contact import jobs", + description: withListPagingNote( + "Returns background contact-import jobs started with `contacts.import`, most recent first. Use `contacts.getImport` for one job's full detail.", + ), tags: ["Contacts"], }) .input(listContactImportsPublicRequest) @@ -42,6 +46,8 @@ export const importPublicRouter = { method: "GET", path: "/v1/contacts/imports/{id}", summary: "Get a contact import job", + description: + "Returns one import job's progress and result counts. Call `contacts.listImports` to find its id first.", tags: ["Contacts"], }) .input(getContactImportPublicRequest) diff --git a/apps/builder/src/features/import/schema/public.ts b/apps/builder/src/features/import/schema/public.ts index 12e1c755b1..23c9ff07ed 100644 --- a/apps/builder/src/features/import/schema/public.ts +++ b/apps/builder/src/features/import/schema/public.ts @@ -26,8 +26,15 @@ export const contactImportPublicResource = z.object({ }) export const listContactImportsPublicRequest = publicListRequest.extend({ - status: importStatuses.optional(), - keyword: z.string().nullish(), + status: importStatuses + .optional() + .describe("Filter to import jobs in this status."), + keyword: z + .string() + .nullish() + .describe( + "Case-insensitive substring match against the import's file name.", + ), }) export const listContactImportsPublicResponse = publicListResponse( @@ -35,5 +42,5 @@ export const listContactImportsPublicResponse = publicListResponse( ) export const getContactImportPublicRequest = z.object({ - id: z.string(), + id: z.string().describe("Import job id. Get it from `contacts.listImports`."), }) diff --git a/apps/builder/src/features/messages/schema/mutation.ts b/apps/builder/src/features/messages/schema/mutation.ts index 4175527f8c..32655fedff 100644 --- a/apps/builder/src/features/messages/schema/mutation.ts +++ b/apps/builder/src/features/messages/schema/mutation.ts @@ -17,35 +17,46 @@ const mediaLibraryFileRequest = z.object({ export const createMessageRequest = z .union([ z.object({ - text: z.string().trim().min(1).max(1000), + text: z.string().trim().min(1).max(1000).describe("Message text."), files: z .array( z.instanceof(File).refine((file) => file.size <= MAX_FILE_SIZE, { message: "Max image size is 5MB.", }), ) - .min(1), + .min(1) + .describe("Media files to attach, up to 5MB each."), }), z.object({ - text: z.string().trim().min(1).max(1000), - mediaFile: mediaLibraryFileRequest, + text: z.string().trim().min(1).max(1000).describe("Message text."), + mediaFile: mediaLibraryFileRequest.describe( + "Media library file reference to attach.", + ), }), // Media Library selection identified by DB id — must be listed before // the text-only branch below, since z.object() strips unknown keys: if // the text-only branch matched first, mediaFileId would be silently // dropped and the message would send as plain text. z.object({ - text: z.string().trim().min(1).max(1000), - mediaFileId: zodBigintAsString(), + text: z.string().trim().min(1).max(1000).describe("Message text."), + mediaFileId: zodBigintAsString().describe( + "Media library file id (numeric string) to attach.", + ), }), // Multi-select Media Library variant — several images sent as one // message. Same union-ordering constraint as mediaFileId above. z.object({ - text: z.string().trim().min(1).max(1000), - mediaFileIds: z.array(zodBigintAsString()).min(1).max(10), + text: z.string().trim().min(1).max(1000).describe("Message text."), + mediaFileIds: z + .array(zodBigintAsString()) + .min(1) + .max(10) + .describe( + "Media library file ids (numeric strings) to attach, up to 10.", + ), }), z.object({ - text: z.string().trim().min(1).max(1000), + text: z.string().trim().min(1).max(1000).describe("Message text."), }), z.object({ files: z @@ -54,34 +65,65 @@ export const createMessageRequest = z message: "Max image size is 5MB.", }), ) - .min(1), + .min(1) + .describe("Media files to attach, up to 5MB each."), }), z.object({ - mediaFile: mediaLibraryFileRequest, + mediaFile: mediaLibraryFileRequest.describe( + "Media library file reference to attach.", + ), }), z.object({ - mediaFileId: zodBigintAsString(), + mediaFileId: zodBigintAsString().describe( + "Media library file id (numeric string) to attach.", + ), }), z.object({ - mediaFileIds: z.array(zodBigintAsString()).min(1).max(10), + mediaFileIds: z + .array(zodBigintAsString()) + .min(1) + .max(10) + .describe( + "Media library file ids (numeric strings) to attach, up to 10.", + ), }), z.object({ - flowId: zodBigintAsString(), - nodeId: zodBigintAsString().optional(), + flowId: zodBigintAsString().describe( + "Flow id (numeric string) to send instead of a plain message. Get it from `flows.list`.", + ), + nodeId: zodBigintAsString() + .optional() + .describe( + "Node id within the flow to start from. Defaults to the flow's start node.", + ), }), ]) .and( z.object({ - inboxId: zodBigintAsString().optional().meta({ - description: + inboxId: zodBigintAsString() + .optional() + .describe( "ID of the channel to send the message on. null to send message on the last interacted channel (if any).", - }), - clientId: zodBigintAsString().optional(), - replyToMessageId: z.string().optional(), - replyToMessageCreatedAt: z.coerce.date().optional(), + ), + clientId: zodBigintAsString() + .optional() + .describe("Client-generated id echoed back for de-duplication."), + replyToMessageId: z + .string() + .optional() + .describe("Id of the message this one replies to."), + replyToMessageCreatedAt: z.coerce + .date() + .optional() + .describe("Creation timestamp of the message this one replies to."), // When true, the outgoing comment is sent as a comment-anchored private // reply DM instead of a public comment reply. - isPrivateReply: z.boolean().optional(), + isPrivateReply: z + .boolean() + .optional() + .describe( + "When true, sends as a comment-anchored private reply DM instead of a public comment reply.", + ), }), ) export type CreateMessageRequest = z.infer diff --git a/apps/builder/src/features/messages/schema/public.ts b/apps/builder/src/features/messages/schema/public.ts index 793bed7d87..1a6cce5720 100644 --- a/apps/builder/src/features/messages/schema/public.ts +++ b/apps/builder/src/features/messages/schema/public.ts @@ -6,24 +6,36 @@ import { PUBLIC_LIST_MAX_PER_PAGE } from "@/lib/public-api/list" // never accepted in the body, per `public-spec-operations.test.ts`'s // zero-exception request-schema sweep. `conversationId` is a path param. export const conversationIdPathParam = z.object({ - conversationId: zodBigintAsString(), + conversationId: zodBigintAsString().describe( + "Conversation id (numeric string). Get it from `conversations.list`.", + ), }) export const listConversationMessagesPublicRequest = z.object({ - conversationId: zodBigintAsString(), + conversationId: zodBigintAsString().describe( + "Conversation id (numeric string). Get it from `conversations.list`.", + ), perPage: z.coerce .number() .int() .min(1) .max(PUBLIC_LIST_MAX_PER_PAGE) .optional() - .default(20), - cursor: z.string().optional(), + .default(20) + .describe("Number of messages per page."), + cursor: z + .string() + .optional() + .describe( + "Opaque pagination cursor from a previous response. Omit for the first page.", + ), }) export const messageIdPathParam = z.object({ - conversationId: zodBigintAsString(), - messageId: zodBigintAsString(), + conversationId: zodBigintAsString().describe( + "Conversation id (numeric string). Get it from `conversations.list`.", + ), + messageId: zodBigintAsString().describe("Message id (numeric string)."), }) // The sharded message store needs `createdAt` to locate a message's shard — @@ -32,10 +44,11 @@ export const messageIdPathParam = z.object({ // parameter (e.g. `?createdAt=...`), not a request body. export const messageIdWithCreatedAtParam = messageIdPathParam.and( z.object({ - createdAt: z.coerce.date().meta({ - description: + createdAt: z.coerce + .date() + .describe( "The message's createdAt timestamp, exactly as returned by GET /v1/conversations/{conversationId}/messages. Required to locate the message in sharded storage. Sent as a query parameter.", - }), + ), }), ) From 956958276a02701b9272da0c15d4741d4a9d0b0d Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:52:18 +0700 Subject: [PATCH 11/38] feat(api): complete flows public API description coverage WS1 batch 2 (partial): finishes apps/builder/src/features/flows/api/public.ts (delete/versions descriptions, publicIdParam for every {id} route, import field descriptions) and describes createFlowSchema/updateFlowSchema/updateDraftFlowVersionSchema/publishFlowSchema fields in schema/action.ts. All 11 flows.* operations now have description+tags+house-style summary+described fields. --- apps/builder/src/features/flows/api/public.ts | 37 ++++++++++++++----- .../src/features/flows/schema/action.ts | 35 +++++++++++++----- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index ff784e1d26..cf03d6a0c5 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -17,6 +17,7 @@ import { possibleErrorsOnMutatingResource, } from "@/lib/orpc/orpc-error-helper" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" +import { publicIdParam } from "@/lib/public-api/params" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { compileAndValidateSpec, @@ -47,7 +48,13 @@ export const flowsPublicRouter = { }) .input( publicListRequest.extend({ - active: z.boolean().optional().default(true), + active: z + .boolean() + .optional() + .default(true) + .describe( + "Restrict to active flows. Set to false to include inactive ones too.", + ), }), ) .output(publicListResponse(flowResource.pick({ id: true, name: true }))) @@ -73,7 +80,7 @@ export const flowsPublicRouter = { tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ id: zodBigintAsString() })) + .input(publicIdParam("flow", "flows.list")) .output(flowWithVersionsResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -115,7 +122,7 @@ export const flowsPublicRouter = { "Partially updates a flow's name, active, or enableInInbox flags.", tags: ["Flows"], }) - .input(updateFlowSchema.and(z.object({ id: zodBigintAsString() }))) + .input(updateFlowSchema.and(publicIdParam("flow", "flows.list"))) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, ...data } = input @@ -127,10 +134,12 @@ export const flowsPublicRouter = { method: "DELETE", path: "/v1/flows/{id}", summary: "Delete a flow", + description: + "Permanently deletes a flow and its draft/published versions. Use `flows.get` to confirm it first.", successStatus: 204, tags: ["Flows"], }) - .input(z.object({ id: zodBigintAsString() })) + .input(publicIdParam("flow", "flows.list")) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await flowService.deleteMany({ @@ -149,7 +158,7 @@ export const flowsPublicRouter = { successStatus: 201, tags: ["Flows"], }) - .input(z.object({ id: zodBigintAsString() })) + .input(publicIdParam("flow", "flows.list")) .output(z.object({ id: z.string() })) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -170,7 +179,7 @@ export const flowsPublicRouter = { tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) - .input(publishFlowRequest.and(z.object({ id: zodBigintAsString() }))) + .input(publishFlowRequest.and(publicIdParam("flow", "flows.list"))) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id } = input @@ -214,7 +223,7 @@ export const flowsPublicRouter = { tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) - .input(updateDraftFlowRequest.and(z.object({ id: zodBigintAsString() }))) + .input(updateDraftFlowRequest.and(publicIdParam("flow", "flows.list"))) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id } = input @@ -236,9 +245,11 @@ export const flowsPublicRouter = { method: "GET", path: "/v1/flows/{id}/versions", summary: "List a flow's published versions", + description: + "Returns every immutable version created by `flows.publish` for this flow, most recent first.", tags: ["Flows"], }) - .input(z.object({ id: zodBigintAsString() })) + .input(publicIdParam("flow", "flows.list")) .output(z.object({ data: z.array(flowVersionResource) })) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -261,8 +272,14 @@ export const flowsPublicRouter = { }) .input( z.object({ - fileId: zodBigintAsString(), - folderId: zodBigintAsString().nullable(), + fileId: zodBigintAsString().describe( + "Id (numeric string) of a previously uploaded flow-export file.", + ), + folderId: zodBigintAsString() + .nullable() + .describe( + "Folder id (numeric string) to import the flow into, or null for no folder.", + ), }), ) .output(z.object({ importId: z.string() })) diff --git a/apps/builder/src/features/flows/schema/action.ts b/apps/builder/src/features/flows/schema/action.ts index f9ecc387a5..f65948bce5 100644 --- a/apps/builder/src/features/flows/schema/action.ts +++ b/apps/builder/src/features/flows/schema/action.ts @@ -8,21 +8,33 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createFlowSchema = z.object({ - folderId: zodBigintAsString().nullable(), - name: z.string().trim().min(1).max(255), + folderId: zodBigintAsString() + .nullable() + .describe( + "Folder id (numeric string) to create the flow in, or null for no folder.", + ), + name: z.string().trim().min(1).max(255).describe("Flow name."), }) export type CreateFlowSchema = z.infer export const updateFlowSchema = z.object({ - name: z.optional(z.string().trim().min(1).max(255)), - active: z.optional(z.boolean()), - enableInInbox: z.optional(z.boolean()), + name: z + .optional(z.string().trim().min(1).max(255)) + .describe("New flow name."), + active: z.optional(z.boolean()).describe("Whether the flow is active."), + enableInInbox: z + .optional(z.boolean()) + .describe("Whether agents can start this flow manually from the inbox."), }) export type UpdateFlowSchema = z.infer export const updateDraftFlowVersionSchema = z.object({ - nodes: z.array(z.any()), - edges: z.array(edgeSchema), + nodes: z + .array(z.any()) + .describe("Raw flow node graph, as sent by the builder UI."), + edges: z + .array(edgeSchema) + .describe("Raw flow edge graph, as sent by the builder UI."), }) export type UpdateDraftFlowVersionSchema = z.infer< typeof updateDraftFlowVersionSchema @@ -43,8 +55,13 @@ export const updateDraftFlowRequest = z.union([ // `@chatbotx.io/flow-config/channel-rules`), so this stays one generic hook // instead of accumulating a refinement per channel/step pair. export const publishFlowSchema = z.object({ - nodes: z.array(flowVersionSchema).superRefine(refineStepsByChannel), - edges: z.array(edgeSchema), + nodes: z + .array(flowVersionSchema) + .superRefine(refineStepsByChannel) + .describe("Raw flow node graph, as sent by the builder UI."), + edges: z + .array(edgeSchema) + .describe("Raw flow edge graph, as sent by the builder UI."), }) export type PublishFlowSchema = z.infer From 93243b6ddc64f22967f8272485317d92e90a3e2d Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:54:04 +0700 Subject: [PATCH 12/38] feat(api): complete sequences public API description coverage WS1 batch 2 (partial): all 7 sequences.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field, including the nested sequence-step schema. --- .../src/features/sequences/api/public.ts | 43 ++++++++-- .../src/features/sequences/schema/action.ts | 79 +++++++++++++++---- .../src/features/sequences/schema/public.ts | 10 ++- 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 3c6c67cecd..34a5da12de 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -57,7 +57,11 @@ export const sequencesPublicRouter = { tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ id: z.string() })) + .input( + z.object({ + id: z.string().describe("Sequence id. Get it from `sequences.list`."), + }), + ) .output(sequenceResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -99,7 +103,15 @@ export const sequencesPublicRouter = { tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) - .input(updateSequenceSchema.and(z.object({ id: zodBigintAsString() }))) + .input( + updateSequenceSchema.and( + z.object({ + id: zodBigintAsString().describe( + "Sequence id. Get it from `sequences.list`.", + ), + }), + ), + ) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const { id, ...data } = input @@ -114,10 +126,17 @@ export const sequencesPublicRouter = { method: "DELETE", path: "/v1/sequences/{id}", summary: "Delete a sequence", + description: "Permanently deletes a sequence and all of its steps.", successStatus: 204, tags: ["Sequences"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Sequence id. Get it from `sequences.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler( async ({ context, input }) => @@ -138,7 +157,11 @@ export const sequencesPublicRouter = { }) .input( publicUpsertSequenceStepRequest.and( - z.object({ id: zodBigintAsString() }), + z.object({ + id: zodBigintAsString().describe( + "Sequence id. Get it from `sequences.list`.", + ), + }), ), ) .output(z.object({ stepId: z.string() })) @@ -165,10 +188,18 @@ export const sequencesPublicRouter = { method: "DELETE", path: "/v1/sequences/{id}/steps/{stepId}", summary: "Delete a sequence step", + description: "Permanently removes one step from a sequence.", successStatus: 204, tags: ["Sequences"], }) - .input(z.object({ id: zodBigintAsString(), stepId: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Sequence id. Get it from `sequences.list`.", + ), + stepId: zodBigintAsString().describe("Sequence step id."), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await sequenceService.assertOwned({ @@ -190,6 +221,8 @@ export const sequencesPublicRouter = { method: "GET", path: "/v1/sequences/{id}/steps/{stepId}/contacts", summary: "List sequence step recipients by event type", + description: + "Returns contacts that reached one lifecycle event (e.g. sent, opened) at one step of a sequence.", tags: ["Sequences"], }) .input(publicListSequenceStepContactsRequest) diff --git a/apps/builder/src/features/sequences/schema/action.ts b/apps/builder/src/features/sequences/schema/action.ts index ed22acb381..2b301ea67b 100644 --- a/apps/builder/src/features/sequences/schema/action.ts +++ b/apps/builder/src/features/sequences/schema/action.ts @@ -48,15 +48,17 @@ export const listSequencesResponse = z.object({ export type ListSequencesResponse = z.infer export const createSequenceRequest = z.object({ - name: z.string().trim().min(1).max(255), - folderId: zodBigintAsString().nullish(), + name: z.string().trim().min(1).max(255).describe("Sequence name."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder id (numeric string) to create the sequence in."), }) export type CreateSequenceRequest = z.infer export const updateSequenceSchema = z .object({ - name: z.string().trim().min(1).max(255), - active: z.boolean(), + name: z.string().trim().min(1).max(255).describe("New sequence name."), + active: z.boolean().describe("Whether the sequence is active."), }) .partial() export type UpdateSequenceSchema = z.infer @@ -66,19 +68,62 @@ export type UpdateSequenceSchema = z.infer // `sequenceId`-less variant below is built by omitting from this base // object first and re-applying `validateStepDelayConsistency` after. const upsertSequenceStepBaseShape = z.object({ - stepId: zodBigintAsString().optional(), - sequenceId: zodBigintAsString(), - order: z.number().int().min(0), - delayDays: z.number().int().min(0).optional(), - delayMinutes: z.number().int().min(0).optional(), - delayUnit: z.enum(DELAY_UNITS).optional(), - specificDateTime: z.iso.datetime().nullable().optional(), - flowId: zodBigintAsString().optional(), - isActive: z.boolean().optional(), - anytime: z.boolean().optional(), - sendTimeStart: z.string().nullable().optional(), - sendTimeEnd: z.string().nullable().optional(), - sendDays: z.array(z.string()).optional(), + stepId: zodBigintAsString() + .optional() + .describe("Existing step id to update. Omit to create a new step."), + sequenceId: zodBigintAsString().describe("Sequence id this step belongs to."), + order: z + .number() + .int() + .min(0) + .describe("Zero-based position of this step within the sequence."), + delayDays: z + .number() + .int() + .min(0) + .optional() + .describe("Delay before this step, in days."), + delayMinutes: z + .number() + .int() + .min(0) + .optional() + .describe("Delay before this step, in minutes."), + delayUnit: z + .enum(DELAY_UNITS) + .optional() + .describe("Unit the delay is expressed in."), + specificDateTime: z.iso + .datetime() + .nullable() + .optional() + .describe( + "Send this step at a specific ISO 8601 date/time instead of a relative delay.", + ), + flowId: zodBigintAsString() + .optional() + .describe( + "Flow id (numeric string) to run at this step. Get it from `flows.list`.", + ), + isActive: z.boolean().optional().describe("Whether this step is active."), + anytime: z + .boolean() + .optional() + .describe("Whether this step can send outside of send-time hours."), + sendTimeStart: z + .string() + .nullable() + .optional() + .describe("Earliest time of day (HH:mm) this step may send."), + sendTimeEnd: z + .string() + .nullable() + .optional() + .describe("Latest time of day (HH:mm) this step may send."), + sendDays: z + .array(z.string()) + .optional() + .describe("Days of the week this step may send on."), }) const validateStepDelayConsistency = ( diff --git a/apps/builder/src/features/sequences/schema/public.ts b/apps/builder/src/features/sequences/schema/public.ts index bc0232d002..f869bdc7ea 100644 --- a/apps/builder/src/features/sequences/schema/public.ts +++ b/apps/builder/src/features/sequences/schema/public.ts @@ -13,9 +13,13 @@ import { publicListRequest } from "@/lib/public-api/list" // variants declared here instead of reusing those directly, mirroring // `broadcasts/schema/public.ts`. export const publicListSequenceStepContactsRequest = z.object({ - id: zodBigintAsString(), - stepId: zodBigintAsString(), - eventType: sequenceStepEventTypes, + id: zodBigintAsString().describe( + "Sequence id. Get it from `sequences.list`.", + ), + stepId: zodBigintAsString().describe("Sequence step id."), + eventType: sequenceStepEventTypes.describe( + "Lifecycle event to filter recipients by (e.g. sent, opened).", + ), page: publicListRequest.shape.page, perPage: publicListRequest.shape.perPage, }) From e8b6528e87c86292f797ba0d1130cf088d1eec22 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:55:40 +0700 Subject: [PATCH 13/38] test(api): shrink description backlog for flows and sequences Removes "flows." and "sequences." from DESCRIPTION_BACKLOG now that both routers fully satisfy every description/tag/summary-style/field-description assertion. Lengthens sequences.deleteStep's description past the 50-char minimum. --- apps/builder/__tests__/public-spec-operations.test.ts | 2 -- apps/builder/src/features/sequences/api/public.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 06ef9fd64b..815b035f8c 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -81,7 +81,6 @@ const DESCRIPTION_BACKLOG = new Set([ "externalWebhooks.", "facebookLeadAds.", "fbComments.", - "flows.", "folders.", "igComments.", "igStories.", @@ -101,7 +100,6 @@ const DESCRIPTION_BACKLOG = new Set([ "reflinks.", "savedReplies.", "schemas.", - "sequences.", "smtpIntegrations.", "spreadsheets.", "tags.", diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 34a5da12de..2511246606 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -188,7 +188,8 @@ export const sequencesPublicRouter = { method: "DELETE", path: "/v1/sequences/{id}/steps/{stepId}", summary: "Delete a sequence step", - description: "Permanently removes one step from a sequence.", + description: + "Permanently removes one step from a sequence, identified by its `stepId`. Use `sequences.get` to see current steps first.", successStatus: 204, tags: ["Sequences"], }) From 9c6743a9c127c980c1cf06d33ecd823af23172e4 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 10:58:31 +0700 Subject: [PATCH 14/38] feat(api): complete keywords public API description coverage WS1 batch 2 (partial): all 6 keywords.* (automated-response) operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "keywords." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../features/automated-response/api/public.ts | 101 ++++++++++++++---- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 815b035f8c..cd43c8a646 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -87,7 +87,6 @@ const DESCRIPTION_BACKLOG = new Set([ "inboxTeams.", "inboxes.", "integrations.", - "keywords.", "mediaLibrary.", "messages.", "messengerChannels.", diff --git a/apps/builder/src/features/automated-response/api/public.ts b/apps/builder/src/features/automated-response/api/public.ts index ab7ea3adc3..7a0c40e3f6 100644 --- a/apps/builder/src/features/automated-response/api/public.ts +++ b/apps/builder/src/features/automated-response/api/public.ts @@ -29,7 +29,9 @@ export const keywordsPublicRouter = { }) .input( publicListRequest.extend({ - type: automatedResponseTypes.default("inbound"), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), }), ) .output(publicListResponse(publicKeywordResource)) @@ -51,12 +53,18 @@ export const keywordsPublicRouter = { method: "GET", path: "/v1/keywords/{id}", summary: "Get a keyword automation by id", + description: + "Returns one keyword automation. Use `keywords.list` to find its id first.", tags: ["Keywords"], }) .input( z.object({ - id: zodBigintAsString(), - type: automatedResponseTypes.default("inbound"), + id: zodBigintAsString().describe( + "Keyword automation id. Get it from `keywords.list`.", + ), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), }), ) .output(publicKeywordResource) @@ -82,11 +90,30 @@ export const keywordsPublicRouter = { }) .input( z.object({ - type: automatedResponseTypes.default("inbound"), - keywords: z.array(z.string().min(1).max(255)).min(1), - text: z.string().min(1).nullish(), - flowId: zodBigintAsString().nullish(), - folderId: zodBigintAsString().nullish(), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), + keywords: z + .array(z.string().min(1).max(255)) + .min(1) + .describe("Keyword phrases that trigger this automation."), + text: z + .string() + .min(1) + .nullish() + .describe( + "Reply text to send when matched. Mutually exclusive with flowId in practice.", + ), + flowId: zodBigintAsString() + .nullish() + .describe( + "Flow id (numeric string) to start when matched instead of sending text.", + ), + folderId: zodBigintAsString() + .nullish() + .describe( + "Folder id (numeric string) to organize this automation under.", + ), }), ) .output(publicKeywordResource) @@ -101,16 +128,38 @@ export const keywordsPublicRouter = { method: "PUT", path: "/v1/keywords/{id}", summary: "Update a keyword automation", + description: + "Overwrites the given fields on an existing keyword automation. Use `keywords.get` to inspect current values first.", tags: ["Keywords"], }) .input( z.object({ - id: zodBigintAsString(), - type: automatedResponseTypes.default("inbound"), - keywords: z.array(z.string().min(1).max(255)).min(1).optional(), - text: z.string().min(1).nullish(), - flowId: zodBigintAsString().nullish(), - folderId: zodBigintAsString().nullish(), + id: zodBigintAsString().describe( + "Keyword automation id. Get it from `keywords.list`.", + ), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), + keywords: z + .array(z.string().min(1).max(255)) + .min(1) + .optional() + .describe("Keyword phrases that trigger this automation."), + text: z + .string() + .min(1) + .nullish() + .describe("Reply text to send when matched."), + flowId: zodBigintAsString() + .nullish() + .describe( + "Flow id (numeric string) to start when matched instead of sending text.", + ), + folderId: zodBigintAsString() + .nullish() + .describe( + "Folder id (numeric string) to organize this automation under.", + ), }), ) .output(publicKeywordResource) @@ -131,13 +180,21 @@ export const keywordsPublicRouter = { method: "PATCH", path: "/v1/keywords/{id}/status", summary: "Enable or disable a keyword automation", + description: + "Toggles whether a keyword automation is active without changing its other fields.", tags: ["Keywords"], }) .input( z.object({ - id: zodBigintAsString(), - status: z.boolean(), - type: automatedResponseTypes.default("inbound"), + id: zodBigintAsString().describe( + "Keyword automation id. Get it from `keywords.list`.", + ), + status: z + .boolean() + .describe("Whether the automation should be active."), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), }), ) .output(publicKeywordResource) @@ -159,13 +216,19 @@ export const keywordsPublicRouter = { method: "DELETE", path: "/v1/keywords/{id}", summary: "Delete a keyword automation", + description: + "Permanently deletes one keyword automation. Use `keywords.list` to find its id first.", successStatus: 204, tags: ["Keywords"], }) .input( z.object({ - id: zodBigintAsString(), - type: automatedResponseTypes.default("inbound"), + id: zodBigintAsString().describe( + "Keyword automation id. Get it from `keywords.list`.", + ), + type: automatedResponseTypes + .default("inbound") + .describe("Automation type: inbound message or comment reply."), }), ) .errors(possibleErrorsOnDeletingResource) From c59b778869aa6fc9f8e41c62aaa8352d35a87319 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:00:24 +0700 Subject: [PATCH 15/38] feat(api): complete AI agents public API description coverage WS1 batch 2 (partial): all 5 aiAgents.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field including createAIAgentRequest/updateAIAgentRequest. Removes "aiAgents." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/ai-agents/api/public.ts | 30 ++++- .../src/features/ai-agents/schema/action.ts | 124 +++++++++++------- 3 files changed, 107 insertions(+), 48 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index cd43c8a646..ac4a2127fb 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -58,7 +58,6 @@ const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ "ads.", - "aiAgents.", "aiFiles.", "aiFunctions.", "aiMcpServers.", diff --git a/apps/builder/src/features/ai-agents/api/public.ts b/apps/builder/src/features/ai-agents/api/public.ts index 92beb4f6af..aa89242ec2 100644 --- a/apps/builder/src/features/ai-agents/api/public.ts +++ b/apps/builder/src/features/ai-agents/api/public.ts @@ -46,9 +46,17 @@ export const aiAgentsPublicRouter = { method: "GET", path: "/v1/ai-agents/{id}", summary: "Get an AI agent by id", + description: + "Returns one AI agent's configuration. Use `aiAgents.list` to find its id first.", tags: ["AI Agents"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI agent id. Get it from `aiAgents.list`.", + ), + }), + ) .output(aiAgentResourceSchema) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -90,7 +98,15 @@ export const aiAgentsPublicRouter = { tags: ["AI Agents"], spec: mcpSpec({ visibility: "default" }), }) - .input(updateAIAgentRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateAIAgentRequest.and( + z.object({ + id: zodBigintAsString().describe( + "AI agent id. Get it from `aiAgents.list`.", + ), + }), + ), + ) .output(aiAgentResourceSchema) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -106,10 +122,18 @@ export const aiAgentsPublicRouter = { method: "DELETE", path: "/v1/ai-agents/{id}", summary: "Delete an AI agent", + description: + "Permanently deletes an AI agent. Use `aiAgents.list` to find its id first.", successStatus: 204, tags: ["AI Agents"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI agent id. Get it from `aiAgents.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await aiAgentService.delete({ diff --git a/apps/builder/src/features/ai-agents/schema/action.ts b/apps/builder/src/features/ai-agents/schema/action.ts index f9164b413a..8d6f61a348 100644 --- a/apps/builder/src/features/ai-agents/schema/action.ts +++ b/apps/builder/src/features/ai-agents/schema/action.ts @@ -19,58 +19,94 @@ const webSearchAuthorizedDomainsSchema = z .max(MAX_WEB_SEARCH_AUTHORIZED_DOMAINS) export const createAIAgentRequest = z.object({ - name: z.string().trim().min(1).max(255), - prompt: z.string().trim().min(1).max(10_000), - messages: z.array( - z.object({ - role: aiMessageRoles, - content: z.string().trim().min(1).max(255), - }), - ), - models: z.array( - z.union([ - z.discriminatedUnion("provider", [ - z.object({ - provider: z.literal(aiProviders.enum.gemini), - model: geminiModels, - }), - z.object({ - provider: z.literal(aiProviders.enum.openai), - model: openaiModels, - }), - z.object({ - provider: z.literal(aiProviders.enum.claude), - model: claudeModels, - }), - z.object({ - provider: z.literal(aiProviders.enum.deepseek), - model: deepseekModels, - }), + name: z.string().trim().min(1).max(255).describe("AI agent name."), + prompt: z + .string() + .trim() + .min(1) + .max(10_000) + .describe("System prompt that defines the agent's behavior."), + messages: z + .array( + z.object({ + role: aiMessageRoles, + content: z.string().trim().min(1).max(255), + }), + ) + .describe( + "Seed conversation history (role/content pairs) shown to the model before user input.", + ), + models: z + .array( + z.union([ + z.discriminatedUnion("provider", [ + z.object({ + provider: z.literal(aiProviders.enum.gemini), + model: geminiModels, + }), + z.object({ + provider: z.literal(aiProviders.enum.openai), + model: openaiModels, + }), + z.object({ + provider: z.literal(aiProviders.enum.claude), + model: claudeModels, + }), + z.object({ + provider: z.literal(aiProviders.enum.deepseek), + model: deepseekModels, + }), + z.object({ + provider: z.literal(aiProviders.enum.openrouter), + model: openrouterModels, + }), + ]), z.object({ - provider: z.literal(aiProviders.enum.openrouter), - model: openrouterModels, + kind: z.literal("openaiCompatible"), + integrationId: z.string().trim().min(1), + model: z.string().trim().min(1), }), ]), - z.object({ - kind: z.literal("openaiCompatible"), - integrationId: z.string().trim().min(1), - model: z.string().trim().min(1), - }), - ]), - ), - temperature: z.number().min(0).max(2), - maxOutputTokens: z.number().min(1).max(32_768), - tools: z.array(z.string()), - webSearchAuthorizedDomains: webSearchAuthorizedDomainsSchema.default([]), - isDefault: z.boolean(), - isRichResponse: z.boolean().default(false), + ) + .describe( + "Ordered fallback list of provider/model pairs to try. The first entry is preferred; later ones are used if it fails.", + ), + temperature: z.number().min(0).max(2).describe("Sampling temperature, 0-2."), + maxOutputTokens: z + .number() + .min(1) + .max(32_768) + .describe("Maximum tokens the model may generate in one reply."), + tools: z + .array(z.string()) + .describe("Tool names this agent is allowed to call."), + webSearchAuthorizedDomains: webSearchAuthorizedDomainsSchema + .default([]) + .describe( + `Domains the agent's web-search tool is restricted to, up to ${MAX_WEB_SEARCH_AUTHORIZED_DOMAINS}. Empty means unrestricted.`, + ), + isDefault: z + .boolean() + .describe("Whether this is the workspace's default AI agent."), + isRichResponse: z + .boolean() + .default(false) + .describe( + "Whether the agent may return rich (card/button) responses instead of plain text.", + ), }) export type CreateAIAgentRequest = z.infer export const updateAIAgentRequest = createAIAgentRequest .extend({ - webSearchAuthorizedDomains: webSearchAuthorizedDomainsSchema, - isRichResponse: z.boolean(), + webSearchAuthorizedDomains: webSearchAuthorizedDomainsSchema.describe( + `Domains the agent's web-search tool is restricted to, up to ${MAX_WEB_SEARCH_AUTHORIZED_DOMAINS}. Empty means unrestricted.`, + ), + isRichResponse: z + .boolean() + .describe( + "Whether the agent may return rich (card/button) responses instead of plain text.", + ), }) .partial() export type UpdateAIAgentRequest = z.infer From 12e0f629b045c4cd535feee57e29f18a74b47c68 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:02:23 +0700 Subject: [PATCH 16/38] feat(api): complete AI files and AI functions public API description coverage WS1 batch 2 (partial): all 4 aiFiles.* and 5 aiFunctions.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "aiFiles." and "aiFunctions." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 2 -- .../src/features/ai-files/api/public.ts | 20 +++++++++-- .../src/features/ai-files/schema/public.ts | 19 +++++++++-- .../src/features/ai-functions/api/public.ts | 34 +++++++++++++++++-- .../features/ai-functions/schema/action.ts | 34 +++++++++++++------ 5 files changed, 89 insertions(+), 20 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index ac4a2127fb..db1d6e7688 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -58,8 +58,6 @@ const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ "ads.", - "aiFiles.", - "aiFunctions.", "aiMcpServers.", "analytics.", "appointmentCalendars.", diff --git a/apps/builder/src/features/ai-files/api/public.ts b/apps/builder/src/features/ai-files/api/public.ts index b2b27afc7b..b890d2d63b 100644 --- a/apps/builder/src/features/ai-files/api/public.ts +++ b/apps/builder/src/features/ai-files/api/public.ts @@ -45,9 +45,17 @@ export const aiFilesPublicRouter = { method: "GET", path: "/v1/ai-files/{id}", summary: "Get an AI file by id", + description: + "Returns one AI knowledge-base file's metadata and processing status. Use `aiFiles.list` to find its id first.", tags: ["AI Files"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI file id. Get it from `aiFiles.list`.", + ), + }), + ) .output(publicAIFileResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -87,10 +95,18 @@ export const aiFilesPublicRouter = { method: "DELETE", path: "/v1/ai-files/{id}", summary: "Delete an AI file", + description: + "Permanently deletes an AI file. Use `aiFiles.list` to find its id first.", successStatus: 204, tags: ["AI Files"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI file id. Get it from `aiFiles.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await aiFileService.delete({ diff --git a/apps/builder/src/features/ai-files/schema/public.ts b/apps/builder/src/features/ai-files/schema/public.ts index 2b965a77e7..0aef43c9bc 100644 --- a/apps/builder/src/features/ai-files/schema/public.ts +++ b/apps/builder/src/features/ai-files/schema/public.ts @@ -11,14 +11,27 @@ export const publicAIFileResource = aiFileResource.extend({ export const createAIFilePublicRequest = z .object({ - name: z.string().trim().min(1).optional(), + name: z + .string() + .trim() + .min(1) + .optional() + .describe("Display name for the file."), file: z .instanceof(File) .refine((file) => file.size <= AI_FILE_MAX_UPLOAD_BYTES, { message: "Max file size is 100MB.", }) - .optional(), - url: z.url().optional(), + .optional() + .describe( + "Multipart file upload, up to 100MB. Provide this or url, not both.", + ), + url: z + .url() + .optional() + .describe( + "URL the server downloads and stores, up to 100MB. Provide this or file, not both.", + ), }) // A plain z.union resolves to the first matching branch and silently // strips the other field as unknown, so `{ file, url }` would drop `url` diff --git a/apps/builder/src/features/ai-functions/api/public.ts b/apps/builder/src/features/ai-functions/api/public.ts index 5dd6bbf5ca..cc47b0b99e 100644 --- a/apps/builder/src/features/ai-functions/api/public.ts +++ b/apps/builder/src/features/ai-functions/api/public.ts @@ -47,9 +47,17 @@ export const aiFunctionsPublicRouter = { method: "GET", path: "/v1/ai-functions/{id}", summary: "Get an AI function by id", + description: + "Returns one AI function's configuration. Use `aiFunctions.list` to find its id first.", tags: ["AI Functions"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI function id. Get it from `aiFunctions.list`.", + ), + }), + ) .output(aiFunctionResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -67,6 +75,8 @@ export const aiFunctionsPublicRouter = { method: "POST", path: "/v1/ai-functions", summary: "Create an AI function", + description: + "Adds a callable AI function definition to the workspace. Use `aiFunctions.list` first to avoid duplicating an existing one.", successStatus: 201, tags: ["AI Functions"], }) @@ -86,9 +96,19 @@ export const aiFunctionsPublicRouter = { method: "PUT", path: "/v1/ai-functions/{id}", summary: "Update an AI function", + description: + "Changes settings on an existing AI function. Call `aiFunctions.list` to resolve its id first.", tags: ["AI Functions"], }) - .input(updateAIFunctionRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateAIFunctionRequest.and( + z.object({ + id: zodBigintAsString().describe( + "AI function id. Get it from `aiFunctions.list`.", + ), + }), + ), + ) .output(aiFunctionResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -104,10 +124,18 @@ export const aiFunctionsPublicRouter = { method: "DELETE", path: "/v1/ai-functions/{id}", summary: "Delete an AI function", + description: + "Permanently deletes an AI function. Use `aiFunctions.list` to find its id first.", successStatus: 204, tags: ["AI Functions"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI function id. Get it from `aiFunctions.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await aiFunctionService.deleteAIFunction({ diff --git a/apps/builder/src/features/ai-functions/schema/action.ts b/apps/builder/src/features/ai-functions/schema/action.ts index ef7cbf5d40..e2c7dcdc7f 100644 --- a/apps/builder/src/features/ai-functions/schema/action.ts +++ b/apps/builder/src/features/ai-functions/schema/action.ts @@ -13,16 +13,30 @@ export const listAIFunctionsResponse = z.object({ export type ListAIFunctionsResponse = z.infer export const createAIFunctionRequest = z.object({ - name: z.string().trim().min(1), - purpose: z.string().trim().nullish(), - dataCollect: z.array( - z.object({ - from: z.string().trim().min(1), - to: z.string().trim().min(1), - }), - ), - outputMessage: z.string().trim().nullish(), - triggerFlowId: zodBigintAsString().nullish(), + name: z.string().trim().min(1).describe("AI function name."), + purpose: z + .string() + .trim() + .nullish() + .describe("Description of when the model should call this function."), + dataCollect: z + .array( + z.object({ + from: z.string().trim().min(1), + to: z.string().trim().min(1), + }), + ) + .describe( + "Field mappings collecting model output into contact/custom fields.", + ), + outputMessage: z + .string() + .trim() + .nullish() + .describe("Message sent to the contact after this function runs."), + triggerFlowId: zodBigintAsString() + .nullish() + .describe("Flow id (numeric string) to start after this function runs."), }) export type CreateAIFunctionRequest = z.infer From c8b3404ee596f488a49cf3cf7c2fdc468ae9df1c Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:04:54 +0700 Subject: [PATCH 17/38] feat(api): complete AI MCP servers and triggers public API description coverage WS1 batch 2 finale: all 5 aiMcpServers.* and 6 triggers.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "aiMcpServers." and "triggers." from DESCRIPTION_BACKLOG, completing WS1 batch 2 (flows, sequences, keywords, aiAgents, aiFiles, aiFunctions, aiMcpServers, triggers) in full. --- .../__tests__/public-spec-operations.test.ts | 2 - .../src/features/ai-mcp-servers/api/public.ts | 36 ++++++++++++-- .../features/ai-mcp-servers/schema/action.ts | 16 ++++-- .../src/features/triggers/api/public.ts | 49 ++++++++++++++++--- .../src/features/triggers/schema/mutation.ts | 16 ++++-- 5 files changed, 99 insertions(+), 20 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index db1d6e7688..0a74df099d 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -58,7 +58,6 @@ const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ "ads.", - "aiMcpServers.", "analytics.", "appointmentCalendars.", "appointmentExternalCalendars.", @@ -101,7 +100,6 @@ const DESCRIPTION_BACKLOG = new Set([ "tags.", "templateMessages.", "token.", - "triggers.", "userPersistentMenus.", "webchats.", "webhooks.", diff --git a/apps/builder/src/features/ai-mcp-servers/api/public.ts b/apps/builder/src/features/ai-mcp-servers/api/public.ts index 6f0c02a244..00668d0906 100644 --- a/apps/builder/src/features/ai-mcp-servers/api/public.ts +++ b/apps/builder/src/features/ai-mcp-servers/api/public.ts @@ -25,6 +25,8 @@ export const aiMcpServersPublicRouter = { method: "GET", path: "/v1/ai-mcp-servers", summary: "List AI MCP servers", + description: + "Use this to resolve configured AI MCP servers before inspecting one with `aiMcpServers.get` or adding one with `aiMcpServers.create`. Returns the servers configured in this workspace.", tags: ["AI MCP Servers"], }) .input(publicListRequest) @@ -43,9 +45,17 @@ export const aiMcpServersPublicRouter = { method: "GET", path: "/v1/ai-mcp-servers/{id}", summary: "Get an AI MCP server by id", + description: + "Returns one AI MCP server's configuration. Use `aiMcpServers.list` to find its id first.", tags: ["AI MCP Servers"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI MCP server id. Get it from `aiMcpServers.list`.", + ), + }), + ) .output(publicAIMcpServerResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -63,6 +73,8 @@ export const aiMcpServersPublicRouter = { method: "POST", path: "/v1/ai-mcp-servers", summary: "Create an AI MCP server", + description: + "Registers a remote MCP server the AI agent can call as a tool. Use `aiMcpServers.list` first to avoid duplicating an existing one.", successStatus: 201, tags: ["AI MCP Servers"], }) @@ -82,9 +94,19 @@ export const aiMcpServersPublicRouter = { method: "PUT", path: "/v1/ai-mcp-servers/{id}", summary: "Update an AI MCP server", + description: + "Changes settings on an existing AI MCP server. Call `aiMcpServers.list` to resolve its id first.", tags: ["AI MCP Servers"], }) - .input(updateAIMcpServerRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateAIMcpServerRequest.and( + z.object({ + id: zodBigintAsString().describe( + "AI MCP server id. Get it from `aiMcpServers.list`.", + ), + }), + ), + ) .output(publicAIMcpServerResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -104,10 +126,18 @@ export const aiMcpServersPublicRouter = { method: "DELETE", path: "/v1/ai-mcp-servers/{id}", summary: "Delete an AI MCP server", + description: + "Permanently deletes an AI MCP server. Use `aiMcpServers.list` to find its id first.", successStatus: 204, tags: ["AI MCP Servers"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "AI MCP server id. Get it from `aiMcpServers.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { const deleted = await aiMcpServerService.delete({ diff --git a/apps/builder/src/features/ai-mcp-servers/schema/action.ts b/apps/builder/src/features/ai-mcp-servers/schema/action.ts index 8cabc871f2..ce42ed2b73 100644 --- a/apps/builder/src/features/ai-mcp-servers/schema/action.ts +++ b/apps/builder/src/features/ai-mcp-servers/schema/action.ts @@ -14,15 +14,21 @@ export const listAIMcpServersResponse = z.object({ export type ListAIMcpServersResponse = z.infer const baseAIMcpServerRequest = z.object({ - url: z.url(), - auth: aiMcpServerAuth, + url: z.url().describe("MCP server endpoint URL."), + auth: aiMcpServerAuth.describe( + "Authentication configuration for connecting to the server.", + ), }) export type BaseAIMcpServerRequest = z.infer export const createAIMcpServerRequest = baseAIMcpServerRequest.extend({ - name: z.string().trim().min(1), - availableTools: z.record(z.string(), z.any()), - selectedTools: z.array(z.string()), + name: z.string().trim().min(1).describe("AI MCP server name."), + availableTools: z + .record(z.string(), z.any()) + .describe("Tools discovered on the server, keyed by tool name."), + selectedTools: z + .array(z.string()) + .describe("Names of the discovered tools the AI agent is allowed to call."), }) export type CreateAIMcpServerRequest = z.infer diff --git a/apps/builder/src/features/triggers/api/public.ts b/apps/builder/src/features/triggers/api/public.ts index d697e1cc07..af039f36c5 100644 --- a/apps/builder/src/features/triggers/api/public.ts +++ b/apps/builder/src/features/triggers/api/public.ts @@ -63,7 +63,13 @@ export const triggersPublicRouter = { description: "Returns a trigger with its real conditions and actions.", tags: ["Triggers"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Trigger id. Get it from `triggers.list`.", + ), + }), + ) .output(triggerResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -104,9 +110,19 @@ export const triggersPublicRouter = { method: "PUT", path: "/v1/triggers/{id}", summary: "Replace a trigger's conditions and actions", + description: + "Overwrites a trigger's full set of conditions and actions. Call `triggers.get` to inspect current values first.", tags: ["Triggers"], }) - .input(updateTriggerSchema.and(z.object({ id: zodBigintAsString() }))) + .input( + updateTriggerSchema.and( + z.object({ + id: zodBigintAsString().describe( + "Trigger id. Get it from `triggers.list`.", + ), + }), + ), + ) .output(triggerResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -131,13 +147,26 @@ export const triggersPublicRouter = { method: "PATCH", path: "/v1/triggers/{id}/settings", summary: "Update a trigger's name or active state", + description: + "Changes a trigger's name or active state without touching its conditions and actions.", tags: ["Triggers"], }) .input( z.object({ - id: zodBigintAsString(), - name: z.string().trim().min(1).max(255).optional(), - active: z.boolean().optional(), + id: zodBigintAsString().describe( + "Trigger id. Get it from `triggers.list`.", + ), + name: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe("New trigger name."), + active: z + .boolean() + .optional() + .describe("Whether the trigger is active."), }), ) .output(triggerResource) @@ -157,10 +186,18 @@ export const triggersPublicRouter = { method: "DELETE", path: "/v1/triggers/{id}", summary: "Delete a trigger", + description: + "Permanently deletes a trigger. Use `triggers.list` to find its id first.", successStatus: 204, tags: ["Triggers"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Trigger id. Get it from `triggers.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await triggerService.deleteMany({ diff --git a/apps/builder/src/features/triggers/schema/mutation.ts b/apps/builder/src/features/triggers/schema/mutation.ts index 057af1021c..89d72dbee0 100644 --- a/apps/builder/src/features/triggers/schema/mutation.ts +++ b/apps/builder/src/features/triggers/schema/mutation.ts @@ -4,13 +4,21 @@ import { allConditions } from "../../conditions/schema" import { allActions } from "../components/actions/schema" export const createTriggerSchema = z.object({ - name: z.string().min(1, "Trigger name is required"), - folderId: zodBigintAsString().nullable(), + name: z.string().min(1, "Trigger name is required").describe("Trigger name."), + folderId: zodBigintAsString() + .nullable() + .describe( + "Folder id (numeric string) to create the trigger in, or null for no folder.", + ), }) export type CreateTriggerSchema = z.infer export const updateTriggerSchema = z.object({ - conditions: z.array(z.union(Object.values(allConditions))), - actions: z.array(z.union(Object.values(allActions))), + conditions: z + .array(z.union(Object.values(allConditions))) + .describe("Conditions that must all match for this trigger to fire."), + actions: z + .array(z.union(Object.values(allActions))) + .describe("Actions to run in order when this trigger fires."), }) export type UpdateTriggerSchema = z.infer From 6c7af5cd9df2f0ac66199db724a688f8afd3ae6f Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:07:08 +0700 Subject: [PATCH 18/38] fix(api): correct contacts filter field reference in descriptions The contactFilter field description in schema/action.ts and schema/query.ts referenced a nonexistent `contactFilter.get` operation. The real lookup endpoint is `contacts.listFilterFields` (GET /v1/contacts/filter-fields, contactsFilterFieldsPublicRouter.listFilterFields). --- apps/builder/src/features/contacts/schema/action.ts | 2 +- apps/builder/src/features/contacts/schema/query.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/builder/src/features/contacts/schema/action.ts b/apps/builder/src/features/contacts/schema/action.ts index 10d1eee68e..794c9aba58 100644 --- a/apps/builder/src/features/contacts/schema/action.ts +++ b/apps/builder/src/features/contacts/schema/action.ts @@ -99,7 +99,7 @@ export const exportContactsFilter = z.object({ contactFilter: contactFilterCriteriaSchema .optional() .describe( - "Structured filter for advanced matching beyond keyword. See `contactFilter.get`.", + "Structured filter for advanced matching beyond keyword. See `contacts.listFilterFields` for the field/operator reference.", ), }) export type ExportContactsFilter = z.infer diff --git a/apps/builder/src/features/contacts/schema/query.ts b/apps/builder/src/features/contacts/schema/query.ts index 739d881363..3c1dc430c6 100644 --- a/apps/builder/src/features/contacts/schema/query.ts +++ b/apps/builder/src/features/contacts/schema/query.ts @@ -59,7 +59,7 @@ export const listContactsRequest = basePaginationRequest.extend({ contactFilterCriteriaSchema .optional() .describe( - "Structured filter (same shape as `contacts.filterFields`) for advanced matching beyond `keyword`. See `contactFilter.get` for the field/operator reference.", + "Structured filter for advanced matching beyond `keyword`. See `contacts.listFilterFields` for the field/operator reference.", ), ), channels: z From fe9cdee69eee20f5e437532c0f6455a4795655fe Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:09:17 +0700 Subject: [PATCH 19/38] feat(api): complete conversations public API description coverage WS1 batch 3 (partial): all 13 conversations.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "conversations." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/conversations/api/public.ts | 79 +++++++++++++++---- .../features/conversations/schema/public.ts | 9 ++- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 0a74df099d..bd2bb6f059 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -68,7 +68,6 @@ const DESCRIPTION_BACKLOG = new Set([ "capabilities.", "channels.", "contactScans.", - "conversations.", "coupons.", "customFields.", "dynamicImages.", diff --git a/apps/builder/src/features/conversations/api/public.ts b/apps/builder/src/features/conversations/api/public.ts index a50ea57c9b..d111dabf3e 100644 --- a/apps/builder/src/features/conversations/api/public.ts +++ b/apps/builder/src/features/conversations/api/public.ts @@ -43,28 +43,61 @@ function jsonQueryParam(schema: z.ZodType) { } const listConversationsQueryRequest = z.object({ - botCategory: conversationBotCategories.optional(), - assignedId: z.string().nullable().optional(), - channel: channelTypes.optional(), - status: jsonQueryParam(z.array(conversationStatuses).optional()), - keyword: z.string().optional(), - botEnabled: z.preprocess((val) => { - if (val === "true") { - return true - } - if (val === "false") { - return false - } - return val - }, z.boolean().nullish()), + botCategory: conversationBotCategories + .optional() + .describe("Restrict to conversations in this bot lifecycle category."), + assignedId: z + .string() + .nullable() + .optional() + .describe( + "Restrict to conversations assigned to this user/inbox-team id (`u_`/`t_`), or null for unassigned.", + ), + channel: channelTypes + .optional() + .describe("Restrict to conversations on this channel."), + status: jsonQueryParam( + z + .array(conversationStatuses) + .optional() + .describe( + "Restrict to conversations in one of these statuses. Sent as a JSON-encoded array.", + ), + ), + keyword: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the conversation's contact.", + ), + botEnabled: z + .preprocess((val) => { + if (val === "true") { + return true + } + if (val === "false") { + return false + } + return val + }, z.boolean().nullish()) + .describe("Restrict to conversations with the bot enabled or disabled."), tags: jsonQueryParam( z .array( z.enum(["noAdminReply", "unread", "followUp", "archived", "blocked"]), ) - .optional(), + .optional() + .describe( + "Restrict to conversations matching one of these system tags. Sent as a JSON-encoded array.", + ), + ), + contactFilter: jsonQueryParam( + contactFilterCriteriaSchema + .optional() + .describe( + "Structured filter for advanced matching on the conversation's contact. Sent as a JSON-encoded object. See `contacts.listFilterFields` for the field/operator reference.", + ), ), - contactFilter: jsonQueryParam(contactFilterCriteriaSchema.optional()), ...cursorPaginationRequest.shape, }) @@ -187,6 +220,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/unarchive", summary: "Unarchive a conversation", + description: + "Reverses `conversations.archive`, restoring the conversation to the default inbox view.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -214,6 +249,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/read", summary: "Mark a conversation as read", + description: + "Clears the unread indicator on a conversation for the workspace.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -242,6 +279,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/unread", summary: "Mark a conversation as unread", + description: + "Reverses `conversations.read`, flagging the conversation as unread again.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -260,6 +299,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/follow", summary: "Follow a conversation", + description: + "Subscribes the calling actor to updates on a conversation. Use `conversations.unfollow` to reverse.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -284,6 +325,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/unfollow", summary: "Unfollow a conversation", + description: + "Reverses `conversations.follow`, unsubscribing from conversation updates.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -308,6 +351,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/enable-bot", summary: "Re-enable the bot for a conversation", + description: + "Turns the bot back on for a conversation after it was handed off to a human with `conversations.disableBot`.", tags: ["Conversations"], }) .input(conversationIdPathParam) @@ -336,6 +381,8 @@ export const conversationsPublicRouter = { method: "POST", path: "/v1/conversations/{id}/disable-bot", summary: "Disable the bot for a conversation (hand off to a human)", + description: + "Turns the bot off for a conversation so a human agent takes over. Use `conversations.enableBot` to reverse.", tags: ["Conversations"], }) .input(conversationIdPathParam) diff --git a/apps/builder/src/features/conversations/schema/public.ts b/apps/builder/src/features/conversations/schema/public.ts index 0abbd39e20..8fb1848959 100644 --- a/apps/builder/src/features/conversations/schema/public.ts +++ b/apps/builder/src/features/conversations/schema/public.ts @@ -14,7 +14,9 @@ import { findConversationResponse } from "./resource" export const getConversationPublicResponse = findConversationResponse export const conversationIdPathParam = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Conversation id. Get it from `conversations.list`.", + ), }) export const assignConversationPublicRequest = z.object({ @@ -25,5 +27,8 @@ export const assignConversationPublicRequest = z.object({ .string() .trim() .regex(/^[ut]_\S+$/, "assignedId must start with 'u_' or 't_'") - .nullable(), + .nullable() + .describe( + "New assignee: `u_` for a user or `t_` for an inbox team, or null to unassign.", + ), }) From 52f6a50179987198fcb132107e8cf2ea63526aca Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:11:06 +0700 Subject: [PATCH 20/38] feat(api): complete messages public API description coverage WS1 batch 3 (partial): all 6 messages.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field including editMessageRequest/changeMessageAttributesRequest. Removes "messages." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/messages/api/public.ts | 8 +++ .../src/features/messages/schema/mutation.ts | 64 +++++++++++++++---- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index bd2bb6f059..134adada56 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -83,7 +83,6 @@ const DESCRIPTION_BACKLOG = new Set([ "inboxes.", "integrations.", "mediaLibrary.", - "messages.", "messengerChannels.", "messengerPersonas.", "minigames.", diff --git a/apps/builder/src/features/messages/api/public.ts b/apps/builder/src/features/messages/api/public.ts index c6ab53ddda..935f18eb98 100644 --- a/apps/builder/src/features/messages/api/public.ts +++ b/apps/builder/src/features/messages/api/public.ts @@ -81,6 +81,8 @@ export const messagesPublicRouter = { method: "GET", path: "/v1/conversations/{conversationId}/messages/{messageId}", summary: "Get a message by id on a conversation", + description: + "Returns one message. Use `messages.list` to find its `messageId`/`createdAt` first.", tags: ["Messages"], }) .input(messageIdWithCreatedAtParam) @@ -146,6 +148,8 @@ export const messagesPublicRouter = { method: "PATCH", path: "/v1/conversations/{conversationId}/messages/{messageId}", summary: "Edit a comment message", + description: + "Edits the text of a comment-origin message (e.g. a Facebook/Instagram comment reply). Not usable for chat messages.", tags: ["Messages"], }) .input(editMessageRequest.omit({ messageId: true }).and(messageIdPathParam)) @@ -164,6 +168,8 @@ export const messagesPublicRouter = { method: "DELETE", path: "/v1/conversations/{conversationId}/messages/{messageId}", summary: "Delete a comment message", + description: + "Permanently deletes a comment-origin message. Use `messages.list` to find its `messageId`/`createdAt` first.", successStatus: 204, tags: ["Messages"], }) @@ -182,6 +188,8 @@ export const messagesPublicRouter = { method: "POST", path: "/v1/conversations/{conversationId}/messages/{messageId}/attributes", summary: "Change a message's liked/hidden attributes", + description: + "Toggles whether a comment-origin message is liked and/or hidden. Use `messages.list` to find its `messageId` first.", tags: ["Messages"], }) .input( diff --git a/apps/builder/src/features/messages/schema/mutation.ts b/apps/builder/src/features/messages/schema/mutation.ts index 32655fedff..6343c76622 100644 --- a/apps/builder/src/features/messages/schema/mutation.ts +++ b/apps/builder/src/features/messages/schema/mutation.ts @@ -178,15 +178,43 @@ export const deleteMessageRequest = z.object({ export type DeleteMessageRequest = z.infer export const editMessageRequest = z.object({ - messageId: zodBigintAsString(), - createdAt: z.coerce.date(), - newText: z.string().trim().min(1).max(2000), - newAttachmentPath: z.string().optional(), - newAttachmentPublicUrl: z.string().optional(), - newAttachmentMimeType: z.string().optional(), - newAttachmentName: z.string().optional(), - newAttachmentSize: z.number().int().optional(), - removeAttachment: z.boolean().optional(), + messageId: zodBigintAsString().describe("Message id."), + createdAt: z.coerce + .date() + .describe( + "The message's createdAt timestamp, exactly as returned by `messages.list`. Required to locate the message.", + ), + newText: z + .string() + .trim() + .min(1) + .max(2000) + .describe("Replacement message text."), + newAttachmentPath: z + .string() + .optional() + .describe("Path of a replacement attachment, if changing it."), + newAttachmentPublicUrl: z + .string() + .optional() + .describe("Public URL of the replacement attachment."), + newAttachmentMimeType: z + .string() + .optional() + .describe("MIME type of the replacement attachment."), + newAttachmentName: z + .string() + .optional() + .describe("Display name of the replacement attachment."), + newAttachmentSize: z + .number() + .int() + .optional() + .describe("Size in bytes of the replacement attachment."), + removeAttachment: z + .boolean() + .optional() + .describe("Whether to remove the message's existing attachment entirely."), }) export type EditMessageRequest = z.infer @@ -205,10 +233,20 @@ export const sendFlowMessageRequest = z.object({ }) export const changeMessageAttributesRequest = z.object({ - messageId: zodBigintAsString(), - createdAt: z.coerce.date(), - liked: z.boolean().optional(), - hidden: z.boolean().optional(), + messageId: zodBigintAsString().describe("Message id."), + createdAt: z.coerce + .date() + .describe( + "The message's createdAt timestamp, exactly as returned by `messages.list`. Required to locate the message.", + ), + liked: z + .boolean() + .optional() + .describe("Whether the message should be marked liked."), + hidden: z + .boolean() + .optional() + .describe("Whether the message should be hidden."), }) export type ChangeMessageAttributesRequest = z.infer< typeof changeMessageAttributesRequest From 128f887f7b150221e9db9308e1d1b06d5a789bd8 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:13:51 +0700 Subject: [PATCH 21/38] feat(api): complete broadcasts public API description coverage WS1 batch 3 (partial): all 14 broadcasts.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field, including the large createBroadcastRequest schema. Rewrites the moveToDraft/resume bare status-constraint fragments into full call-order descriptions per user decision #3. Removes "broadcasts." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/broadcasts/api/public.ts | 120 +++++++++++++++--- .../src/features/broadcasts/schema/action.ts | 77 ++++++++--- .../src/features/broadcasts/schema/public.ts | 8 +- 4 files changed, 171 insertions(+), 35 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 134adada56..107abca2c6 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -64,7 +64,6 @@ const DESCRIPTION_BACKLOG = new Set([ "appointmentReminders.", "appointments.", "botFields.", - "broadcasts.", "capabilities.", "channels.", "contactScans.", diff --git a/apps/builder/src/features/broadcasts/api/public.ts b/apps/builder/src/features/broadcasts/api/public.ts index 193b2d64a0..7b48c2b378 100644 --- a/apps/builder/src/features/broadcasts/api/public.ts +++ b/apps/builder/src/features/broadcasts/api/public.ts @@ -84,7 +84,15 @@ export const broadcastsPublicRouter = { tags: ["Broadcasts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ idOrName: z.string() })) + .input( + z.object({ + idOrName: z + .string() + .describe( + "Broadcast id (numeric string) or exact name. Get it from `broadcasts.list`.", + ), + }), + ) .output(publicBroadcastResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -100,13 +108,29 @@ export const broadcastsPublicRouter = { method: "GET", path: "/v1/broadcasts/{idOrName}/audience", summary: "Get broadcast audience", + description: + "Returns the paginated audience list a broadcast was or will be sent to. Use `broadcasts.get` to find its id or name first.", tags: ["Broadcasts"], }) .input( z.object({ - idOrName: z.string(), - page: z.coerce.number().int().min(1).optional(), - perPage: z.coerce.number().int().min(1).optional(), + idOrName: z + .string() + .describe( + "Broadcast id (numeric string) or exact name. Get it from `broadcasts.list`.", + ), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number, starting at 1."), + perPage: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Number of items per page."), }), ) .output(listBroadcastAudienceResponse) @@ -130,6 +154,8 @@ export const broadcastsPublicRouter = { method: "GET", path: "/v1/broadcasts/{id}/contacts", summary: "List broadcast recipients by event type", + description: + "Returns contacts that reached one delivery event (e.g. sent, delivered, read, failed) for a broadcast.", tags: ["Broadcasts"], }) .input(publicListBroadcastContactsRequest) @@ -177,9 +203,19 @@ export const broadcastsPublicRouter = { method: "PATCH", path: "/v1/broadcasts/{id}", summary: "Rename a broadcast", + description: + "Changes a broadcast's name only. Use `broadcasts.updateDraft` to change a draft's full payload.", tags: ["Broadcasts"], }) - .input(updateBroadcastSchema.and(z.object({ id: zodBigintAsString() }))) + .input( + updateBroadcastSchema.and( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ), + ) .output(publicBroadcastResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -203,7 +239,15 @@ export const broadcastsPublicRouter = { "Replaces a draft's complete payload and can schedule it when `saveAsDraft` is false. Call `broadcasts.get` to inspect the draft first, or use `broadcasts.schedule` to keep its payload.", tags: ["Broadcasts"], }) - .input(createBroadcastRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + createBroadcastRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ), + ) // `status` is what tells the caller whether `saveAsDraft: false` actually // promoted the draft to `scheduled` — the service already computes it, so // declaring it here avoids a follow-up GET (zod strips undeclared keys @@ -229,7 +273,15 @@ export const broadcastsPublicRouter = { "Moves a draft broadcast to its scheduled state using the provided schedule. Call `broadcasts.get` to inspect it first, or use `broadcasts.updateDraft` to change its payload.", tags: ["Broadcasts"], }) - .input(scheduleBroadcastSchema.and(z.object({ id: zodBigintAsString() }))) + .input( + scheduleBroadcastSchema.and( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ), + ) .output(z.object({ id: z.string() })) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -247,10 +299,17 @@ export const broadcastsPublicRouter = { method: "POST", path: "/v1/broadcasts/{id}/move-to-draft", summary: "Move a scheduled broadcast back to draft", - description: "Only matches a broadcast whose status is scheduled.", + description: + "Reverses a broadcast's `scheduled` state so its payload can be edited again. Only matches a broadcast whose status is `scheduled`; 404 otherwise. Use `broadcasts.updateDraft` afterward, or `broadcasts.schedule` to re-schedule.", tags: ["Broadcasts"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .output(z.object({ id: z.string() })) .errors(possibleErrorsOnMutatingResource) .handler( @@ -271,7 +330,13 @@ export const broadcastsPublicRouter = { tags: ["Broadcasts"], spec: mcpSpec({ visibility: "default" }), }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .output(z.object({ id: z.string() })) .errors(possibleErrorsOnMutatingResource) .handler( @@ -287,10 +352,17 @@ export const broadcastsPublicRouter = { method: "POST", path: "/v1/broadcasts/{id}/resume", summary: "Resume a stopped broadcast", - description: "Only matches a broadcast whose status is cancelled.", + description: + "Resumes sending a stopped broadcast where it left off. Only matches a broadcast whose status is `cancelled`; 404 otherwise. Use `broadcasts.stop` to pause a sending broadcast.", tags: ["Broadcasts"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .output(z.object({ id: z.string() })) .errors(possibleErrorsOnMutatingResource) .handler( @@ -311,7 +383,13 @@ export const broadcastsPublicRouter = { successStatus: 201, tags: ["Broadcasts"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .output(publicBroadcastResource) .errors(possibleErrorsOnMutatingResource) .handler( @@ -333,7 +411,13 @@ export const broadcastsPublicRouter = { successStatus: 201, tags: ["Broadcasts"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .output(publicBroadcastResource) .errors(possibleErrorsOnMutatingResource) .handler( @@ -355,7 +439,13 @@ export const broadcastsPublicRouter = { successStatus: 204, tags: ["Broadcasts"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { // `softDeleteBroadcasts` is a bulk method: it reports skipped ids via diff --git a/apps/builder/src/features/broadcasts/schema/action.ts b/apps/builder/src/features/broadcasts/schema/action.ts index 42d1d40da6..7f1bc48d01 100644 --- a/apps/builder/src/features/broadcasts/schema/action.ts +++ b/apps/builder/src/features/broadcasts/schema/action.ts @@ -66,18 +66,46 @@ export type BroadcastTargetRequest = z.infer export const createBroadcastRequest = z .object({ - channel: channelTypes, - flowId: zodBigintAsString().optional(), - templateId: zodBigintAsString().optional(), - integrationWhatsappId: zodBigintAsString().optional(), - integrationMessengerId: zodBigintAsString().optional(), - templateData: broadcastTemplateDataSchema.optional(), - buttons: broadcastTemplateButtonsSchema.optional(), - targets: z.array(broadcastTargetSchema).optional(), + channel: channelTypes.describe("Channel to send the broadcast over."), + flowId: zodBigintAsString() + .optional() + .describe( + "Flow id (numeric string) to send. Provide this or templateId, not both.", + ), + templateId: zodBigintAsString() + .optional() + .describe( + "WhatsApp template id (numeric string) to send. Provide this or flowId, not both.", + ), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe("WhatsApp integration id (numeric string) to send from."), + integrationMessengerId: zodBigintAsString() + .optional() + .describe("Messenger integration id (numeric string) to send from."), + templateData: broadcastTemplateDataSchema + .optional() + .describe( + "Parameters for the WhatsApp template, when sending a single-page broadcast.", + ), + buttons: broadcastTemplateButtonsSchema + .optional() + .describe("Button overrides for the WhatsApp template."), + targets: z + .array(broadcastTargetSchema) + .optional() + .describe( + "Per-page targets for a multi-page broadcast, each with its own template/flow.", + ), /** The page multi-select's value; `targets` mirrors it and is what the server reads. */ - inboxIds: z.array(zodBigintAsString()).optional(), - subaction: broadcastSubactions, - schedulesType: broadcastScheduleTypes, + inboxIds: z + .array(zodBigintAsString()) + .optional() + .describe("Inbox ids (numeric strings) this broadcast sends from."), + subaction: broadcastSubactions.describe("Audience sub-action filter."), + schedulesType: broadcastScheduleTypes.describe( + "When to send: immediately (`now`) or at `schedulesAt` (`future`).", + ), // Future-ness is validated by the `superRefine` below, not here: that // check has the full object (`schedulesType`, `saveAsDraft`) and is the // only one that can tell a schedule actually being set (validate) from @@ -85,9 +113,19 @@ export const createBroadcastRequest = z // field-level `.refine` here ran unconditionally on any non-null value, // so it blocked re-saving an untouched `future` draft once its // previously-chosen `schedulesAt` elapsed. - schedulesAt: z.string().nullable(), - contactFilter: contactFilterRequest.shape.contactFilter, - saveAsDraft: z.boolean().optional(), + schedulesAt: z + .string() + .nullable() + .describe( + "ISO 8601 send time, required when schedulesType is `future` and not a draft.", + ), + contactFilter: contactFilterRequest.shape.contactFilter.describe( + "Structured filter selecting the recipient audience. See `contacts.listFilterFields`.", + ), + saveAsDraft: z + .boolean() + .optional() + .describe("Save as a draft instead of scheduling/sending immediately."), }) .refine( (data) => !!(broadcastSendsFlow(data) || broadcastSendsTemplate(data)), @@ -170,14 +208,19 @@ export const createBroadcastRequest = z export type CreateBroadcastRequest = z.infer export const updateBroadcastSchema = z.object({ - name: z.string().trim().min(1).max(255), + name: z.string().trim().min(1).max(255).describe("New broadcast name."), }) export type UpdateBroadcastSchema = z.infer export const scheduleBroadcastSchema = z .object({ - schedulesType: broadcastScheduleTypes, - schedulesAt: z.string().nullable(), + schedulesType: broadcastScheduleTypes.describe( + "When to send: immediately (`now`) or at `schedulesAt` (`future`).", + ), + schedulesAt: z + .string() + .nullable() + .describe("ISO 8601 send time, required when schedulesType is `future`."), }) .superRefine((data, ctx) => { if ( diff --git a/apps/builder/src/features/broadcasts/schema/public.ts b/apps/builder/src/features/broadcasts/schema/public.ts index 6972cb9687..b28e79bad3 100644 --- a/apps/builder/src/features/broadcasts/schema/public.ts +++ b/apps/builder/src/features/broadcasts/schema/public.ts @@ -10,8 +10,12 @@ import { publicListRequest } from "@/lib/public-api/list" // navigation detail, not a public API concern) — narrow variants declared // here instead of reusing those directly, mirroring `analytics/schema/public.ts`. export const publicListBroadcastContactsRequest = z.object({ - id: zodBigintAsString(), - eventType: broadcastEventType, + id: zodBigintAsString().describe( + "Broadcast id. Get it from `broadcasts.list`.", + ), + eventType: broadcastEventType.describe( + "Delivery event to filter recipients by (e.g. sent, delivered, read, failed).", + ), page: publicListRequest.shape.page, perPage: publicListRequest.shape.perPage, }) From 29b999da09f396f7be3729460ff4b15f9cc9a291 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:15:31 +0700 Subject: [PATCH 22/38] feat(api): complete saved replies public API description coverage WS1 batch 3 (partial): all 5 savedReplies.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "savedReplies." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/saved-replies/api/public.ts | 36 +++++++++++++++++-- .../features/saved-replies/schema/mutation.ts | 9 +++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 107abca2c6..7dad1594fd 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -90,7 +90,6 @@ const DESCRIPTION_BACKLOG = new Set([ "qrCodes.", "questionnaires.", "reflinks.", - "savedReplies.", "schemas.", "smtpIntegrations.", "spreadsheets.", diff --git a/apps/builder/src/features/saved-replies/api/public.ts b/apps/builder/src/features/saved-replies/api/public.ts index f7c1bca7e5..8ecccf3a84 100644 --- a/apps/builder/src/features/saved-replies/api/public.ts +++ b/apps/builder/src/features/saved-replies/api/public.ts @@ -31,6 +31,8 @@ export const savedRepliesPublicRouter = { method: "GET", path: "/v1/saved-replies", summary: "List saved replies", + description: + "Use this to find saved-reply shortcuts before inspecting one with `savedReplies.get` or adding one with `savedReplies.create`. Returns the shortcuts available in this workspace.", tags: ["Saved Replies"], }) .input(publicListRequest) @@ -48,9 +50,17 @@ export const savedRepliesPublicRouter = { method: "GET", path: "/v1/saved-replies/{id}", summary: "Get a saved reply by id", + description: + "Returns one saved reply. Use `savedReplies.list` to find its id first.", tags: ["Saved Replies"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Saved reply id. Get it from `savedReplies.list`.", + ), + }), + ) .output(savedReplyResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -66,6 +76,8 @@ export const savedRepliesPublicRouter = { method: "POST", path: "/v1/saved-replies", summary: "Create a saved reply", + description: + "Adds a shortcut-triggered text snippet agents can insert into a reply. Use `savedReplies.list` first to avoid duplicating a shortcut.", successStatus: 201, tags: ["Saved Replies"], }) @@ -86,9 +98,19 @@ export const savedRepliesPublicRouter = { method: "PUT", path: "/v1/saved-replies/{id}", summary: "Update a saved reply", + description: + "Changes a saved reply's shortcut and/or text. Call `savedReplies.get` to inspect current values first.", tags: ["Saved Replies"], }) - .input(editSavedReplyRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + editSavedReplyRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Saved reply id. Get it from `savedReplies.list`.", + ), + }), + ), + ) .output(savedReplyResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -105,10 +127,18 @@ export const savedRepliesPublicRouter = { method: "DELETE", path: "/v1/saved-replies/{id}", summary: "Delete a saved reply", + description: + "Permanently deletes a saved reply. Use `savedReplies.list` to find its id first.", successStatus: 204, tags: ["Saved Replies"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Saved reply id. Get it from `savedReplies.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await savedReplyService.delete({ diff --git a/apps/builder/src/features/saved-replies/schema/mutation.ts b/apps/builder/src/features/saved-replies/schema/mutation.ts index 5cc6544b6e..e2ce80853e 100644 --- a/apps/builder/src/features/saved-replies/schema/mutation.ts +++ b/apps/builder/src/features/saved-replies/schema/mutation.ts @@ -8,8 +8,13 @@ export const listSavedRepliesRequest = z.object({ export type ListSavedRepliesRequest = z.infer export const createSavedReplyRequest = z.object({ - shortcut: z.string().trim().min(1).max(100), - text: z.string().trim().min(1).max(2000), + shortcut: z + .string() + .trim() + .min(1) + .max(100) + .describe("Trigger text agents type to insert this reply."), + text: z.string().trim().min(1).max(2000).describe("Reply text to insert."), }) export type CreateSavedReplyRequest = z.infer From 3573fe41f357183d1126db23330e5e2d009092f3 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:44:47 +0700 Subject: [PATCH 23/38] feat(api): complete inboxes public API description coverage WS1 batch 3 (partial): both inboxes.* operations (list, listChannels) already had descriptions; describes the shared listInboxesRequest fields in packages/business/src/inbox/schema.ts. Removes "inboxes." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - packages/business/src/inbox/schema.ts | 21 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 7dad1594fd..1e75593646 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -79,7 +79,6 @@ const DESCRIPTION_BACKLOG = new Set([ "igComments.", "igStories.", "inboxTeams.", - "inboxes.", "integrations.", "mediaLibrary.", "messengerChannels.", diff --git a/packages/business/src/inbox/schema.ts b/packages/business/src/inbox/schema.ts index 301e69dace..9965252d67 100644 --- a/packages/business/src/inbox/schema.ts +++ b/packages/business/src/inbox/schema.ts @@ -11,9 +11,24 @@ import { integrationZaloResource } from "../integration-zalo/schema" export const listInboxesRequest = z.object({ workspaceId: zodBigintAsString(), - includes: z.array(z.literal("integration")).optional(), - page: z.coerce.number().int().min(1).optional(), - perPage: z.coerce.number().int().min(1).optional(), + includes: z + .array(z.literal("integration")) + .optional() + .describe( + 'Relations to embed. Pass ["integration"] to include each inbox\'s channel integration.', + ), + page: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Page number, starting at 1."), + perPage: z.coerce + .number() + .int() + .min(1) + .optional() + .describe("Number of items per page."), }) export type ListInboxesRequest = z.infer From bda93538eecc49d5ad0a07c8670bb34bda1a2e59 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:46:46 +0700 Subject: [PATCH 24/38] feat(api): complete inbox teams public API description coverage WS1 batch 3 finale: all 7 inboxTeams.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "inboxTeams." from DESCRIPTION_BACKLOG, completing WS1 batch 3 (conversations, messages, broadcasts, savedReplies, inboxes, inboxTeams) in full. --- .../__tests__/public-spec-operations.test.ts | 1 - .../features/inbox-teams/api/public.ts | 18 +++++++++++++++++- .../features/inbox-teams/schema/action.ts | 12 ++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 1e75593646..c45deeb46a 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -78,7 +78,6 @@ const DESCRIPTION_BACKLOG = new Set([ "folders.", "igComments.", "igStories.", - "inboxTeams.", "integrations.", "mediaLibrary.", "messengerChannels.", diff --git a/apps/builder/src/enterprise/features/inbox-teams/api/public.ts b/apps/builder/src/enterprise/features/inbox-teams/api/public.ts index b876af61b9..c52aa20ba0 100644 --- a/apps/builder/src/enterprise/features/inbox-teams/api/public.ts +++ b/apps/builder/src/enterprise/features/inbox-teams/api/public.ts @@ -22,7 +22,9 @@ import { inboxTeamResource } from "../schema/resource" const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("inbox") -const inboxTeamIdPathParam = z.object({ id: zodBigintAsString() }) +const inboxTeamIdPathParam = z.object({ + id: zodBigintAsString().describe("Team id. Get it from `inboxTeams.list`."), +}) export const inboxTeamsPublicRouter = { list: workspaceTokenAuthAPI @@ -30,6 +32,8 @@ export const inboxTeamsPublicRouter = { method: "GET", path: "/v1/teams", summary: "List teams", + description: + "Use this to find inbox-team ids before inspecting one with `inboxTeams.get` or assigning a conversation to it. Returns the teams in this workspace.", tags: ["Teams"], }) .input(publicListRequest) @@ -47,6 +51,8 @@ export const inboxTeamsPublicRouter = { method: "GET", path: "/v1/teams/{id}", summary: "Get a team by id", + description: + "Returns one inbox team and its members. Use `inboxTeams.list` to find its id first.", tags: ["Teams"], }) .input(inboxTeamIdPathParam) @@ -65,6 +71,8 @@ export const inboxTeamsPublicRouter = { method: "POST", path: "/v1/teams", summary: "Create a team", + description: + "Adds an inbox team that a conversation can be assigned to. Use `inboxTeams.list` first to avoid duplicating an existing team.", successStatus: 201, tags: ["Teams"], }) @@ -84,6 +92,8 @@ export const inboxTeamsPublicRouter = { method: "PUT", path: "/v1/teams/{id}", summary: "Update a team", + description: + "Changes an inbox team's settings. Call `inboxTeams.get` to inspect current values first.", tags: ["Teams"], }) .input(updateInboxTeamRequest.and(inboxTeamIdPathParam)) @@ -103,6 +113,8 @@ export const inboxTeamsPublicRouter = { method: "DELETE", path: "/v1/teams/{id}", summary: "Delete a team", + description: + "Permanently deletes an inbox team. Use `inboxTeams.list` to find its id first.", successStatus: 204, tags: ["Teams"], }) @@ -120,6 +132,8 @@ export const inboxTeamsPublicRouter = { method: "POST", path: "/v1/teams/{id}/members", summary: "Add members to a team", + description: + "Adds the given user ids to an inbox team's membership. Use `inboxTeams.removeMembers` to remove them.", tags: ["Teams"], }) .input(addInboxTeamMemberRequest.and(inboxTeamIdPathParam)) @@ -138,6 +152,8 @@ export const inboxTeamsPublicRouter = { method: "DELETE", path: "/v1/teams/{id}/members", summary: "Remove members from a team", + description: + "Removes the given user ids from an inbox team's membership. Use `inboxTeams.addMembers` to add them.", tags: ["Teams"], }) .input(addInboxTeamMemberRequest.and(inboxTeamIdPathParam)) diff --git a/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts b/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts index a8f400afd2..450f0daa89 100644 --- a/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts +++ b/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts @@ -5,18 +5,22 @@ import { inboxTeamMemberResource } from "../../inbox-team-members/schema/resourc import { inboxTeamResource } from "./resource" export const createInboxTeamRequest = z.object({ - name: z.string().trim().min(1).max(255), - userIds: z.array(zodBigintAsString()), + name: z.string().trim().min(1).max(255).describe("Team name."), + userIds: z + .array(zodBigintAsString()) + .describe("User ids (numeric strings) to add as initial team members."), }) export type CreateInboxTeamRequest = z.infer export const updateInboxTeamRequest = z.object({ - name: z.string().trim().min(1).max(255).optional(), + name: z.string().trim().min(1).max(255).optional().describe("New team name."), }) export type UpdateInboxTeamRequest = z.infer export const addInboxTeamMemberRequest = z.object({ - userIds: z.array(zodBigintAsString()), + userIds: z + .array(zodBigintAsString()) + .describe("User ids (numeric strings) to add or remove."), }) export type AddInboxTeamMemberRequest = z.infer< typeof addInboxTeamMemberRequest From c8fdddd70feb53a21323f36967404573f16b195d Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:52:39 +0700 Subject: [PATCH 25/38] feat(api): complete analytics public API description coverage WS1 batch 4 (partial): all 31 analytics.* operations gain description, tags, and house-style summaries. Describes the shared time-range/granularity/broadcast/sequence/flow/magic-link request schemas in packages/analytics/src/schemas once at the source, covering every analytics route's field-description requirement. Removes "analytics." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/analytics/api/public.ts | 44 +++++++++++++++++++ .../src/features/analytics/schema/public.ts | 4 +- .../analytics/src/schemas/broadcast-stats.ts | 4 +- packages/analytics/src/schemas/common.ts | 31 +++++++++---- packages/analytics/src/schemas/flow-stats.ts | 2 +- packages/analytics/src/schemas/magic-link.ts | 33 +++++++++----- .../analytics/src/schemas/sequence-stats.ts | 4 +- 8 files changed, 99 insertions(+), 24 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index c45deeb46a..66bc44b57f 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -58,7 +58,6 @@ const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ "ads.", - "analytics.", "appointmentCalendars.", "appointmentExternalCalendars.", "appointmentReminders.", diff --git a/apps/builder/src/features/analytics/api/public.ts b/apps/builder/src/features/analytics/api/public.ts index 8730fb52a9..967adf5dc7 100644 --- a/apps/builder/src/features/analytics/api/public.ts +++ b/apps/builder/src/features/analytics/api/public.ts @@ -91,6 +91,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/contact-counts-per-day", summary: "Get contact counts per day", + description: + "Use this to chart total contacts over a requested time range. Compare it with `analytics.newContactCountsPerDay` to isolate acquisition from the running total.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -151,6 +153,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/blocked-contacts-count", summary: "Get blocked contacts count", + description: + "Counts contacts blocked within the given `from`/`to` time range. Use `analytics.blockedContactsPerDay` for a daily breakdown instead of a single total.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -248,6 +252,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/active-contacts-count", summary: "Get active contacts count", + description: + "Counts contacts with at least one channel interaction within the given `from`/`to` time range (monthly active contacts).", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -280,6 +286,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/contacts-by-dimension", summary: "Get contacts by dimension", + description: + "Groups contact counts by country, channel, or source over a time range. Set `dimension` to choose the grouping.", tags: ["Analytics"], }) .input(contactsByDimensionPublicRequest) @@ -311,6 +319,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/messages-by-admin", summary: "Get messages sent by admin", + description: + "Counts outgoing messages sent by human agents over a requested time range. Compare with `analytics.messagesBySender` for a per-sender breakdown.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -329,6 +339,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/human-agent-stats", summary: "Get human agent statistics", + description: + "Returns response-time and volume statistics for human agents over a requested time range.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -347,6 +359,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/conversation-handoffs", summary: "Get conversation handoffs", + description: + "Counts conversations handed off from the bot to a human agent, by day, over a requested time range.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -365,6 +379,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/conversation-followups", summary: "Get conversation follow-ups", + description: + "Counts conversations flagged for follow-up, by day, over a requested time range.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -383,6 +399,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/conversation-archived", summary: "Get archived conversations", + description: + "Counts conversations archived, by day, over a requested time range. Use `analytics.conversationAssigned` for assignment trends instead.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -401,6 +419,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/conversation-assigned", summary: "Get assigned conversations", + description: + "Counts conversations assigned to an agent, by day, over a requested time range. Use `analytics.conversationAssignedByAdmin` for a per-admin breakdown.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -419,6 +439,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/conversation-assigned-by-admin", summary: "Get assigned conversations by admin", + description: + "Counts conversations assigned, broken down by the admin who assigned them, over a requested time range.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -437,6 +459,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/unique-conversations-by-admin", summary: "Get unique conversations by admin", + description: + "Counts distinct conversations each admin handled, over a requested time range. Use `analytics.conversationAssignedByAdmin` for assignment counts instead.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -456,6 +480,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/bot-messages-by-result", summary: "Get bot messages by result", + description: + "Counts bot messages grouped by their outcome (e.g. answered, fallback), over a requested time range with `granularity` bucketing.", tags: ["Analytics"], }) .input(timeRangeWithGranularityMHDPublicRequest) @@ -474,6 +500,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/bot-messages-with-response", summary: "Get bot messages with response", + description: + "Counts bot messages that received a follow-up reply, over a requested time range with `granularity` bucketing.", tags: ["Analytics"], }) .input(timeRangeWithGranularityMHDPublicRequest) @@ -492,6 +520,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/bot-messages-no-response", summary: "Get bot messages with no response", + description: + "Counts bot messages that did not receive a follow-up reply, over a requested time range with `granularity` bucketing.", tags: ["Analytics"], }) .input(timeRangeWithGranularityMHDPublicRequest) @@ -510,6 +540,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/bot-messages-ai-providers", summary: "Get bot messages AI providers", + description: + "Counts bot messages grouped by the AI provider that generated them, over a requested time range.", tags: ["Analytics"], }) .input(timeRangePublicRequest) @@ -528,6 +560,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/messages-by-sender", summary: "Get messages by sender", + description: + "Counts messages grouped by sender (bot vs human agent), over a requested time range with `granularity` bucketing.", tags: ["Analytics"], }) .input(timeRangeWithGranularityDMPublicRequest) @@ -599,6 +633,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/mac/active-count", summary: "Get current period MAC count for the workspace", + description: + "Returns the workspace's monthly active contact count for the current billing period. Use `analytics.activeContactsCount` for an arbitrary date range instead.", tags: ["Analytics"], }) .output(macActiveContactCountPublicResponse) @@ -642,6 +678,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/magic-links/stats", summary: "Get magic link stats", + description: + "Returns click/conversion counts for one magic link over a time range. Use `analytics.magicLinkContacts` to list the contacts behind those counts.", tags: ["Analytics"], }) .input(linkStatsPublicRequest) @@ -659,6 +697,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/magic-links/contacts", summary: "Get magic link contacts", + description: + "Lists the contacts who clicked one magic link over a time range. Use `analytics.magicLinkStats` for aggregate counts instead.", tags: ["Analytics"], }) .input(linkContactsPublicRequest) @@ -677,6 +717,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/ref-links/stats", summary: "Get ref link stats", + description: + "Returns click/conversion counts for one ref link over a time range. Use `analytics.refLinkContacts` to list the contacts behind those counts.", tags: ["Analytics"], }) .input(linkStatsPublicRequest) @@ -695,6 +737,8 @@ export const analyticsPublicRouter = { method: "GET", path: "/v1/analytics/ref-links/contacts", summary: "Get ref link contacts", + description: + "Lists the contacts who clicked one ref link over a time range. Use `analytics.refLinkStats` for aggregate counts instead.", tags: ["Analytics"], }) .input(linkContactsPublicRequest) diff --git a/apps/builder/src/features/analytics/schema/public.ts b/apps/builder/src/features/analytics/schema/public.ts index e3d968eace..a482bea41b 100644 --- a/apps/builder/src/features/analytics/schema/public.ts +++ b/apps/builder/src/features/analytics/schema/public.ts @@ -46,7 +46,9 @@ export const timeRangeWithGranularityDMPublicRequest = timeRangeQueryWithGranularityDMSchema.omit({ workspaceId: true }) export const contactsByDimensionPublicRequest = timeRangePublicRequest.extend({ - dimension: z.enum(["country", "channel", "source"]), + dimension: z + .enum(["country", "channel", "source"]) + .describe("How to group contact counts."), }) // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/analytics/src/schemas/broadcast-stats.ts b/packages/analytics/src/schemas/broadcast-stats.ts index 6fb5c849d3..60f63bdff0 100644 --- a/packages/analytics/src/schemas/broadcast-stats.ts +++ b/packages/analytics/src/schemas/broadcast-stats.ts @@ -15,7 +15,9 @@ export type BroadcastEventType = MessageEventType | FlowEventType export const getBroadcastStatsRequest = z.object({ workspaceId: z.string(), - broadcastId: z.string(), + broadcastId: z + .string() + .describe("Broadcast id. Get it from `broadcasts.list`."), }) export type GetBroadcastStatsRequest = z.infer diff --git a/packages/analytics/src/schemas/common.ts b/packages/analytics/src/schemas/common.ts index be89e9553d..c5bd947295 100644 --- a/packages/analytics/src/schemas/common.ts +++ b/packages/analytics/src/schemas/common.ts @@ -4,12 +4,23 @@ import { z } from "zod" const FROM_FLOOR = new Date("2020-01-01T00:00:00.000Z") export const timeRangeQuerySchema = z.object({ - from: z.string().transform((val) => { - const d = new Date(val) - return d < FROM_FLOOR ? FROM_FLOOR : d - }), - to: z.string().transform((val) => new Date(val)), - timezone: z.string().default("UTC"), + from: z + .string() + .transform((val) => { + const d = new Date(val) + return d < FROM_FLOOR ? FROM_FLOOR : d + }) + .describe( + "ISO 8601 start of the time range (inclusive). Clamped to 2020-01-01 at the earliest.", + ), + to: z + .string() + .transform((val) => new Date(val)) + .describe("ISO 8601 end of the time range (exclusive)."), + timezone: z + .string() + .default("UTC") + .describe("IANA timezone used to bucket results, e.g. `America/New_York`."), workspaceId: zodBigintAsString(), }) export type TimeRangeQuery = z.infer @@ -19,12 +30,16 @@ export const granularityDayMonthSchema = z.enum(["day", "month"]) export const timeRangeQueryWithGranularityMHDSchema = timeRangeQuerySchema.extend({ - granularity: granularityDayHourMinuteSchema.default("day"), + granularity: granularityDayHourMinuteSchema + .default("day") + .describe("Bucket size for the returned time series."), }) export const timeRangeQueryWithGranularityDMSchema = timeRangeQuerySchema.extend({ - granularity: granularityDayMonthSchema.default("day"), + granularity: granularityDayMonthSchema + .default("day") + .describe("Bucket size for the returned time series."), }) export const contactInfoSchema = z.object({ diff --git a/packages/analytics/src/schemas/flow-stats.ts b/packages/analytics/src/schemas/flow-stats.ts index 6cbc5bb2a9..6738a5e063 100644 --- a/packages/analytics/src/schemas/flow-stats.ts +++ b/packages/analytics/src/schemas/flow-stats.ts @@ -6,7 +6,7 @@ import { z } from "zod" export const flowStatsRequest = z.object({ workspaceId: z.string(), - flowId: z.string(), + flowId: z.string().describe("Flow id. Get it from `flows.list`."), }) export type FlowStatsRequest = z.infer diff --git a/packages/analytics/src/schemas/magic-link.ts b/packages/analytics/src/schemas/magic-link.ts index bc853a8367..6489d46f92 100644 --- a/packages/analytics/src/schemas/magic-link.ts +++ b/packages/analytics/src/schemas/magic-link.ts @@ -2,22 +2,35 @@ import { z } from "zod" export const magicLinkStatsSchema = z.object({ workspaceId: z.string(), - startDate: z.string(), - endDate: z.string(), - linkId: z.string(), - timezone: z.string(), + startDate: z + .string() + .describe("ISO 8601 start of the time range (inclusive)."), + endDate: z.string().describe("ISO 8601 end of the time range (exclusive)."), + linkId: z.string().describe("Magic link or ref link id."), + timezone: z + .string() + .describe("IANA timezone used to bucket results, e.g. `America/New_York`."), }) export type MagicLinkStatsInput = z.infer export const magicLinkContactStatsSchema = z.object({ workspaceId: z.string(), - linkId: z.string(), - page: z.number(), - perPage: z.number(), - startDate: z.string().optional(), - endDate: z.string().optional(), - timezone: z.string().optional(), + linkId: z.string().describe("Magic link or ref link id."), + page: z.number().describe("Page number, starting at 1."), + perPage: z.number().describe("Number of items per page."), + startDate: z + .string() + .optional() + .describe("ISO 8601 start of the time range (inclusive)."), + endDate: z + .string() + .optional() + .describe("ISO 8601 end of the time range (exclusive)."), + timezone: z + .string() + .optional() + .describe("IANA timezone used to bucket results, e.g. `America/New_York`."), }) export type MagicLinkContactStatsInput = z.infer< diff --git a/packages/analytics/src/schemas/sequence-stats.ts b/packages/analytics/src/schemas/sequence-stats.ts index bec57445f5..37d980afc4 100644 --- a/packages/analytics/src/schemas/sequence-stats.ts +++ b/packages/analytics/src/schemas/sequence-stats.ts @@ -14,8 +14,8 @@ export type SequenceStepEventType = z.infer export const getSequenceStepStatsRequest = z.object({ workspaceId: z.string(), - sequenceId: z.string(), - stepId: z.string(), + sequenceId: z.string().describe("Sequence id. Get it from `sequences.list`."), + stepId: z.string().describe("Sequence step id."), }) export type GetSequenceStepStatsRequest = z.infer< From 0b7e66907644a42463fba5e9048a001d88bfbcbb Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:53:55 +0700 Subject: [PATCH 26/38] feat(api): complete capabilities and schemas public API description coverage WS1 batch 4 (partial): capabilities.get and schemas.flowSpec already had descriptions from the WS3 commit; describes the include query param's array schema. Removes "capabilities." and "schemas." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 2 -- .../src/features/capabilities/api/public.ts | 28 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 66bc44b57f..b5cef741d3 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -63,7 +63,6 @@ const DESCRIPTION_BACKLOG = new Set([ "appointmentReminders.", "appointments.", "botFields.", - "capabilities.", "channels.", "contactScans.", "coupons.", @@ -87,7 +86,6 @@ const DESCRIPTION_BACKLOG = new Set([ "qrCodes.", "questionnaires.", "reflinks.", - "schemas.", "smtpIntegrations.", "spreadsheets.", "tags.", diff --git a/apps/builder/src/features/capabilities/api/public.ts b/apps/builder/src/features/capabilities/api/public.ts index 1c634762ec..e1509a46f7 100644 --- a/apps/builder/src/features/capabilities/api/public.ts +++ b/apps/builder/src/features/capabilities/api/public.ts @@ -19,16 +19,24 @@ import { workspaceTokenAuthAPIForScope } from "@/orpc" // disappearing without a trace. const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("contacts") -const includeQueryParam = z.preprocess((value) => { - if (typeof value !== "string") { - return value - } - const parts = value - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - return parts.length > 0 ? parts : undefined -}, z.array(z.enum(CAPABILITIES_INCLUDES)).optional()) +const includeQueryParam = z.preprocess( + (value) => { + if (typeof value !== "string") { + return value + } + const parts = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + return parts.length > 0 ? parts : undefined + }, + z + .array(z.enum(CAPABILITIES_INCLUDES)) + .optional() + .describe( + "Comma-separated list of capability categories to include. Omit to get the default set.", + ), +) const flowSpecJsonSchemaConverter = new ZodToJsonSchemaConverter() // `flowSpecSchema` is static — converted once at module load rather than on From de1d6a38539042a5c97503582cd47fed642bafb8 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 11:56:49 +0700 Subject: [PATCH 27/38] feat(api): complete token/errorLogs/workspaceMembers/webhooks/externalWebhooks coverage WS1 batch 4 finale: token.get already had a description; errorLogs/workspaceMembers/webhooks/externalWebhooks operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes the remaining batch 4 prefixes from DESCRIPTION_BACKLOG, completing WS1 batch 4 (analytics, capabilities, token, schemas, errorLogs, workspaceMembers, webhooks, externalWebhooks) in full. --- .../__tests__/public-spec-operations.test.ts | 5 ---- .../src/features/error-logs/schema/query.ts | 5 +++- .../features/external-webhooks/api/public.ts | 28 ++++++++++++++++--- .../src/features/webhooks/api/public.ts | 28 ++++++++++++++++--- .../features/workspace-members/api/public.ts | 4 +++ .../workspace-members/schema/query.ts | 12 ++++++-- 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index b5cef741d3..34f10903b6 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -69,8 +69,6 @@ const DESCRIPTION_BACKLOG = new Set([ "customFields.", "dynamicImages.", "emailTopics.", - "errorLogs.", - "externalWebhooks.", "facebookLeadAds.", "fbComments.", "folders.", @@ -90,11 +88,8 @@ const DESCRIPTION_BACKLOG = new Set([ "spreadsheets.", "tags.", "templateMessages.", - "token.", "userPersistentMenus.", "webchats.", - "webhooks.", - "workspaceMembers.", "zaloChannels.", ]) diff --git a/apps/builder/src/features/error-logs/schema/query.ts b/apps/builder/src/features/error-logs/schema/query.ts index 48cea7d4b7..95770bf54c 100644 --- a/apps/builder/src/features/error-logs/schema/query.ts +++ b/apps/builder/src/features/error-logs/schema/query.ts @@ -21,7 +21,10 @@ export const listErrorLogsSearchParamsCache = createSearchParamsCache({ }) export const listErrorLogsRequest = basePaginationRequest.extend({ - keyword: z.string().optional(), + keyword: z + .string() + .optional() + .describe("Case-insensitive substring match against the error message."), workspaceId: z.string(), }) diff --git a/apps/builder/src/features/external-webhooks/api/public.ts b/apps/builder/src/features/external-webhooks/api/public.ts index ba64679ed4..c02d13b08c 100644 --- a/apps/builder/src/features/external-webhooks/api/public.ts +++ b/apps/builder/src/features/external-webhooks/api/public.ts @@ -54,9 +54,21 @@ export const externalWebhooksPublicRouter = { }) .input( z.object({ - url: z.string().trim().url(), - event: z.string().trim().min(1).max(100), - provider: z.enum(["make", "n8n"]).default("make"), + url: z + .string() + .trim() + .url() + .describe("URL to receive HTTP POST requests."), + event: z + .string() + .trim() + .min(1) + .max(100) + .describe("Event name to subscribe to."), + provider: z + .enum(["make", "n8n"]) + .default("make") + .describe("Automation platform registering this webhook."), }), ) .output(externalWebhookResource) @@ -76,10 +88,18 @@ export const externalWebhooksPublicRouter = { method: "DELETE", path: "/v1/external-webhooks/{id}", summary: "Unregister an external webhook", + description: + "Permanently deletes a registered external webhook. Use `externalWebhooks.list` to find its id first.", successStatus: 204, tags: ["External Webhooks"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "External webhook id. Get it from `externalWebhooks.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await externalWebhookService.unregister({ diff --git a/apps/builder/src/features/webhooks/api/public.ts b/apps/builder/src/features/webhooks/api/public.ts index ed23905514..639619745d 100644 --- a/apps/builder/src/features/webhooks/api/public.ts +++ b/apps/builder/src/features/webhooks/api/public.ts @@ -27,6 +27,8 @@ export const webhooksPublicRouter = { method: "GET", path: "/v1/webhooks", summary: "List webhooks", + description: + "Use this to find registered webhook ids before removing one with `webhooks.delete`. Returns webhooks registered in this workspace.", tags: ["Webhooks"], }) .input(publicListRequest) @@ -53,9 +55,19 @@ export const webhooksPublicRouter = { }) .input( z.object({ - name: z.string().trim().min(1).max(255), - url: z.string().trim().url().max(1000), - conditions: z.array(conditionSchema).min(1), + name: z.string().trim().min(1).max(255).describe("Webhook name."), + url: z + .string() + .trim() + .url() + .max(1000) + .describe( + "URL to receive HTTP POST requests when a matching event occurs.", + ), + conditions: z + .array(conditionSchema) + .min(1) + .describe("Event conditions that trigger this webhook."), }), ) .output(webhookResource) @@ -76,10 +88,18 @@ export const webhooksPublicRouter = { method: "DELETE", path: "/v1/webhooks/{id}", summary: "Unregister a webhook", + description: + "Permanently deletes a registered webhook. Use `webhooks.list` to find its id first.", successStatus: 204, tags: ["Webhooks"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Webhook id. Get it from `webhooks.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await webhookService.unregister({ diff --git a/apps/builder/src/features/workspace-members/api/public.ts b/apps/builder/src/features/workspace-members/api/public.ts index 411f9287e0..0137d91e69 100644 --- a/apps/builder/src/features/workspace-members/api/public.ts +++ b/apps/builder/src/features/workspace-members/api/public.ts @@ -21,6 +21,8 @@ export const workspaceMembersPublicRouter = { method: "GET", path: "/v1/members", summary: "List workspace members", + description: + "Use this to find workspace member ids before inspecting one with `workspaceMembers.get`. Returns members in this workspace.", tags: ["Members"], }) .input( @@ -41,6 +43,8 @@ export const workspaceMembersPublicRouter = { method: "GET", path: "/v1/members/{memberId}", summary: "Get workspace member by id", + description: + "Returns one workspace member. Use `workspaceMembers.list` to find its id first.", tags: ["Members"], }) .input(getWorkspaceMemberRequest.omit({ workspaceId: true })) diff --git a/apps/builder/src/features/workspace-members/schema/query.ts b/apps/builder/src/features/workspace-members/schema/query.ts index 570ffdecc2..ae3eb089fd 100644 --- a/apps/builder/src/features/workspace-members/schema/query.ts +++ b/apps/builder/src/features/workspace-members/schema/query.ts @@ -23,7 +23,13 @@ export type GetWorkspaceMembersSchema = Awaited< export const listWorkspaceMembersRequest = basePaginationRequest.extend({ workspaceId: zodBigintAsString(), - keyword: z.string().nullish().default(null), + keyword: z + .string() + .nullish() + .default(null) + .describe( + "Case-insensitive substring match against the member's name or email.", + ), }) export type ListWorkspaceMembersRequest = z.infer< typeof listWorkspaceMembersRequest @@ -42,7 +48,9 @@ export type ListWorkspaceMembersResponse = z.infer< > export const getWorkspaceMemberRequest = z.object({ - memberId: zodBigintAsString(), + memberId: zodBigintAsString().describe( + "Workspace member id. Get it from `workspaceMembers.list`.", + ), workspaceId: zodBigintAsString(), }) export type GetWorkspaceMemberRequest = z.infer< From 6a0dbeb8791a7b205484363dcdcc7cee464d7330 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 12:12:07 +0700 Subject: [PATCH 28/38] feat(api): complete ads and ads-campaign public API description coverage WS1 batch 5 (partial): all 31 ads.* operations (16 from ads.ts's conversion-rules/analytics routers, 15 from ads-campaign.ts's messaging-ads router, both spread into the same adsPublicRouter) gain description, tags, house-style summaries, and .describe() on every top-level input field. Describes the shared ads-conversion-rule/ads-retarget request schemas in packages/business at their source. Removes "ads." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/ads-campaign/api/public.ts | 30 +++ .../features/ads-campaign/schema/public.ts | 208 ++++++++++++++---- apps/builder/src/features/ads/api/public.ts | 32 +++ .../builder/src/features/ads/schema/public.ts | 155 ++++++++++--- .../business/src/ads-conversion/schema.ts | 72 ++++-- packages/business/src/ads-retarget/service.ts | 63 +++++- 7 files changed, 456 insertions(+), 105 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 34f10903b6..9ecceda658 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -57,7 +57,6 @@ const LEGACY_WORKSPACE_TOKEN_PATTERN = /workspace[_.]?token/i const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ - "ads.", "appointmentCalendars.", "appointmentExternalCalendars.", "appointmentReminders.", diff --git a/apps/builder/src/features/ads-campaign/api/public.ts b/apps/builder/src/features/ads-campaign/api/public.ts index fe172017a7..928eb759a2 100644 --- a/apps/builder/src/features/ads-campaign/api/public.ts +++ b/apps/builder/src/features/ads-campaign/api/public.ts @@ -95,6 +95,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: "/v1/ads/campaigns", summary: "Create a messaging ad", + description: + "Starts a draft click-to-message ad campaign (campaign/ad set/ad) for the given audience and creative. Use `ads.publishCampaign` to publish it once ready.", successStatus: 201, tags: ["Ads"], }) @@ -131,6 +133,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: "/v1/ads/campaigns/{operationId}/retry", summary: "Resume messaging ad creation", + description: + "Retries a draft messaging ad's creation after a previous attempt failed partway through. Use `ads.listCampaigns` to find its `operationId` first.", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -149,6 +153,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: "/v1/ads/campaigns/{operationId}/publish", summary: "Publish a messaging ad", + description: + "Publishes a draft messaging ad's campaign/ad set/ad to Meta so it starts delivering. Use `ads.pauseCampaign` to pause it afterward.", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -167,6 +173,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: "/v1/ads/campaigns/{operationId}/pause", summary: "Pause a published messaging ad on Meta", + description: + "Pauses delivery of a published messaging ad without deleting it. There is no dedicated resume operation — publish again or edit via Meta directly.", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -185,6 +193,8 @@ export const adsCampaignPublicRouter = { method: "DELETE", path: "/v1/ads/campaigns/{operationId}", summary: "Delete a messaging ad's campaign/ad set/ad on Meta", + description: + "Permanently removes a messaging ad's campaign/ad set/ad from Meta. Use `ads.listCampaigns` to find its `operationId` first.", tags: ["Ads"], }) .input(operationIdPublicParams) @@ -203,6 +213,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns", summary: "List messaging ads", + description: + "Use this to find messaging-ad `operationId`s before publishing, pausing, or deleting one. Returns messaging ads created in this workspace.", tags: ["Ads"], }) .input(listMessagingAdsPublicRequest) @@ -229,6 +241,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: ADS_CAMPAIGNS_INSIGHTS_PATH, summary: "Get messaging ad insights", + description: + "Returns delivery/spend insights for the given ad ids. POST (not GET) since `adIds` can be up to 500 entries, too large for a query string.", tags: ["Ads"], }) .input(messagingAdsInsightsPublicRequest) @@ -253,6 +267,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns/{channel}/{integrationId}/ad-accounts", summary: "List integration ad accounts", + description: + "Lists ad accounts available for a channel integration's messaging-ads connection. Use `ads.checkCampaignPrerequisites` first to confirm a connection exists.", tags: ["Ads"], }) .input(listAdAccountsPublicRequestParams.and(listAdAccountsPublicRequest)) @@ -271,6 +287,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns/ad-accounts/{adAccountId}", summary: "Get ad account details", + description: + "Returns one ad account's details (name, currency, status). Use `ads.listCampaignAdAccounts` to find its id first.", tags: ["Ads"], }) .input( @@ -291,6 +309,8 @@ export const adsCampaignPublicRouter = { method: "POST", path: "/v1/ads/campaigns/upload-video", summary: "Upload campaign video", + description: + "Uploads a video for use as ad creative. Returns a `videoId`; poll `ads.getCampaignVideoStatus` until it's ready before referencing it in `ads.createCampaign`.", tags: ["Ads"], }) .input(uploadAdVideoPublicRequest) @@ -317,6 +337,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns/videos/{videoId}/status", summary: "Get campaign video status", + description: + "Returns processing status for a video uploaded with `ads.uploadCampaignVideo`. Poll until `isReady` is true or `isError` is true.", tags: ["Ads"], }) .input(videoStatusPublicRequestParams.and(videoStatusPublicRequest)) @@ -345,6 +367,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns/messenger-pages", summary: "List Messenger pages", + description: + "Only supported for the whatsapp channel — returns pages available for click-to-WhatsApp ad creative.", tags: ["Ads"], }) .input(listMessengerPagesPublicRequest) @@ -376,6 +400,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/campaigns/prerequisites", summary: "Check messaging ads prerequisites", + description: + "Reports whether an active messaging-ads connection exists for the given channel integration. Use `ads.listConnections` to inspect connections directly.", tags: ["Ads"], }) .input(checkPrerequisitesPublicRequest) @@ -395,6 +421,8 @@ export const adsCampaignPublicRouter = { method: "GET", path: "/v1/ads/connections", summary: "List messaging-ads connections for a channel", + description: + "Returns messaging-ads connections for one channel, including their status. Use `ads.disconnectConnection` to remove one.", tags: ["Ads"], }) .input(listConnectionsPublicRequestParams) @@ -426,6 +454,8 @@ export const adsCampaignPublicRouter = { method: "DELETE", path: "/v1/ads/connections/{channel}/{integrationId}", summary: "Disconnect messaging ads connection", + description: + "Permanently disconnects a messaging-ads connection for a channel integration. Use `ads.listConnections` to find it first.", successStatus: 204, tags: ["Ads"], }) diff --git a/apps/builder/src/features/ads-campaign/schema/public.ts b/apps/builder/src/features/ads-campaign/schema/public.ts index f563130a23..f65ec7fb8f 100644 --- a/apps/builder/src/features/ads-campaign/schema/public.ts +++ b/apps/builder/src/features/ads-campaign/schema/public.ts @@ -21,46 +21,108 @@ import { * special-ad-category country, adSet time ordering). */ export const createMessagingAdPublicRequest = z.object({ - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), - whatsappPageIntegrationId: zodBigintAsString().optional(), + channel: messagingAdChannelSchema.describe( + "Channel to run the messaging ad on.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), + whatsappPageIntegrationId: zodBigintAsString() + .optional() + .describe( + "Messenger page integration id, required when channel is whatsapp.", + ), adAccountId: z .string() .trim() - .regex(/^act_\d+$/), - name: z.string().trim().min(1).max(120), - campaign: z.object({ - specialAdCategories: z.array(specialAdCategorySchema).min(1), - specialAdCategoryCountry: z.array(z.string().trim().length(2)).optional(), - }), - adSet: z.object({ - dailyBudgetMinorUnits: z.coerce.number().int().positive(), - targeting: messagingAdTargetingSchema, - startTime: z.string().trim().optional(), - endTime: z.string().trim().optional(), - }), - creative: z.object({ - media: creativeMediaSchema, - welcomeMessage: welcomeMessageSchema, - }), + .regex(/^act_\d+$/) + .describe( + "Meta ad account id (act_). Get it from `ads.listCampaignAdAccounts`.", + ), + name: z.string().trim().min(1).max(120).describe("Campaign name."), + campaign: z + .object({ + specialAdCategories: z + .array(specialAdCategorySchema) + .min(1) + .describe( + "Meta special ad category classifications this campaign falls under.", + ), + specialAdCategoryCountry: z + .array(z.string().trim().length(2)) + .optional() + .describe( + "ISO 3166-1 alpha-2 country codes required for some special ad categories.", + ), + }) + .describe("Campaign-level settings."), + adSet: z + .object({ + dailyBudgetMinorUnits: z.coerce + .number() + .int() + .positive() + .describe( + "Daily budget in the ad account's currency minor units (e.g. cents).", + ), + targeting: messagingAdTargetingSchema.describe( + "Audience targeting for the ad set.", + ), + startTime: z + .string() + .trim() + .optional() + .describe("ISO 8601 ad set start time."), + endTime: z + .string() + .trim() + .optional() + .describe("ISO 8601 ad set end time."), + }) + .describe("Ad set-level settings."), + creative: z + .object({ + media: creativeMediaSchema.describe( + "Ad creative media (image or video).", + ), + welcomeMessage: welcomeMessageSchema.describe( + "Click-to-message welcome message shown to the contact.", + ), + }) + .describe("Ad creative settings."), }) export type CreateMessagingAdPublicRequest = z.infer< typeof createMessagingAdPublicRequest > export const operationIdPublicParams = z.object({ - operationId: zodBigintAsString(), + operationId: zodBigintAsString().describe( + "Messaging ad operation id. Get it from `ads.listCampaigns`.", + ), }) const messagingAdsIntegrationIdentityPublicShape = { - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), + channel: messagingAdChannelSchema.describe( + "Channel the integration belongs to.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), } export const listMessagingAdsPublicRequest = z.object({ - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), - refresh: z.boolean().optional(), + channel: messagingAdChannelSchema.describe( + "Channel the integration belongs to.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), + refresh: z + .boolean() + .optional() + .describe( + "Force an uncached refresh from Meta instead of serving cached data.", + ), }) const MAX_INSIGHTS_AD_IDS = 500 @@ -70,10 +132,22 @@ export const messagingAdsInsightsPublicRequest = z.object({ adAccountId: z .string() .trim() - .regex(/^act_\d+$/), - adIds: z.array(z.string().trim().min(1)).min(1).max(MAX_INSIGHTS_AD_IDS), - datePreset: messagingAdsInsightsDatePresetSchema.optional(), - refresh: z.boolean().optional(), + .regex(/^act_\d+$/) + .describe("Meta ad account id (act_)."), + adIds: z + .array(z.string().trim().min(1)) + .min(1) + .max(MAX_INSIGHTS_AD_IDS) + .describe(`Ad ids to fetch insights for, up to ${MAX_INSIGHTS_AD_IDS}.`), + datePreset: messagingAdsInsightsDatePresetSchema + .optional() + .describe("Meta date preset for the insights window."), + refresh: z + .boolean() + .optional() + .describe( + "Force an uncached refresh from Meta instead of serving cached data.", + ), }) export const listAdAccountsPublicRequestParams = z.object({ @@ -81,19 +155,32 @@ export const listAdAccountsPublicRequestParams = z.object({ }) export const listAdAccountsPublicRequest = z.object({ - refresh: z.boolean().optional(), + refresh: z + .boolean() + .optional() + .describe( + "Force an uncached refresh from Meta instead of serving cached data.", + ), }) export const adAccountDetailsPublicRequestParams = z.object({ adAccountId: z .string() .trim() - .regex(/^act_\d+$/), + .regex(/^act_\d+$/) + .describe( + "Meta ad account id (act_). Get it from `ads.listCampaignAdAccounts`.", + ), }) export const adAccountDetailsPublicRequest = z.object({ ...messagingAdsIntegrationIdentityPublicShape, - refresh: z.boolean().optional(), + refresh: z + .boolean() + .optional() + .describe( + "Force an uncached refresh from Meta instead of serving cached data.", + ), }) // Deliberately LOWER than the private route's 140MB cap @@ -117,14 +204,33 @@ export const uploadAdVideoPublicRequest = z.object({ adAccountId: z .string() .trim() - .regex(/^act_\d+$/), - fileName: z.string().trim().min(1).max(255), - mimeType: z.string().trim().regex(VIDEO_MIME_RE), - base64: z.string().trim().min(1).max(MAX_VIDEO_BASE64_LENGTH), + .regex(/^act_\d+$/) + .describe("Meta ad account id (act_)."), + fileName: z + .string() + .trim() + .min(1) + .max(255) + .describe("File name for the uploaded video."), + mimeType: z + .string() + .trim() + .regex(VIDEO_MIME_RE) + .describe("Video MIME type: video/mp4 or video/quicktime."), + base64: z + .string() + .trim() + .min(1) + .max(MAX_VIDEO_BASE64_LENGTH) + .describe("Base64-encoded video file contents, up to 25MB."), }) export const videoStatusPublicRequestParams = z.object({ - videoId: z.string().trim().min(1), + videoId: z + .string() + .trim() + .min(1) + .describe("Video id returned by `ads.uploadCampaignVideo`."), }) export const videoStatusPublicRequest = z.object({ @@ -132,13 +238,21 @@ export const videoStatusPublicRequest = z.object({ }) export const listMessengerPagesPublicRequest = z.object({ - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), + channel: messagingAdChannelSchema.describe( + "Channel the integration belongs to.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), }) export const checkPrerequisitesPublicRequest = z.object({ - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), + channel: messagingAdChannelSchema.describe( + "Channel the integration belongs to.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), }) // ───────────────────────────────────────────────────────────────────────── @@ -146,7 +260,9 @@ export const checkPrerequisitesPublicRequest = z.object({ // ───────────────────────────────────────────────────────────────────────── export const listConnectionsPublicRequestParams = z.object({ - channel: messagingAdChannelSchema, + channel: messagingAdChannelSchema.describe( + "Channel to list messaging-ads connections for.", + ), }) // Never `auth` (an encrypted credential blob) or `workspaceId`. @@ -169,6 +285,10 @@ export const listConnectionsPublicResponse = z.object({ }) export const disconnectConnectionPublicRequestParams = z.object({ - channel: messagingAdChannelSchema, - integrationId: zodBigintAsString(), + channel: messagingAdChannelSchema.describe( + "Channel the integration belongs to.", + ), + integrationId: zodBigintAsString().describe( + "Channel integration id (numeric string).", + ), }) diff --git a/apps/builder/src/features/ads/api/public.ts b/apps/builder/src/features/ads/api/public.ts index 3f3e6b04e4..5ab006d543 100644 --- a/apps/builder/src/features/ads/api/public.ts +++ b/apps/builder/src/features/ads/api/public.ts @@ -51,6 +51,8 @@ const adsConversionRulesPublicRouter = { method: "GET", path: "/v1/ads/conversion-rules", summary: "List Ads conversion rules", + description: + "Use this to find conversion rule ids before inspecting one with `ads.getRule` or changing one with `ads.updateRule`. Returns rules configured in this workspace.", tags: ["Ads"], }) .input(listAdsConversionRulesPublicRequest) @@ -74,6 +76,8 @@ const adsConversionRulesPublicRouter = { method: "GET", path: "/v1/ads/conversion-rules/{id}", summary: "Get an Ads conversion rule", + description: + "Returns one conversion rule's configuration. Use `ads.listRules` to find its id first.", tags: ["Ads"], }) .input(adsConversionRuleIdParams) @@ -91,6 +95,8 @@ const adsConversionRulesPublicRouter = { method: "POST", path: "/v1/ads/conversion-rules", summary: "Create an Ads conversion rule", + description: + "Adds a rule mapping a channel event to a conversion. Use `ads.listRules` first to avoid duplicating an existing rule.", successStatus: 201, tags: ["Ads"], }) @@ -109,6 +115,8 @@ const adsConversionRulesPublicRouter = { method: "PUT", path: "/v1/ads/conversion-rules/{id}", summary: "Update an Ads conversion rule", + description: + "Changes an existing conversion rule's configuration. Call `ads.getRule` to inspect current values first.", tags: ["Ads"], }) .input(adsConversionRuleIdParams.and(updateAdsConversionRulePublicRequest)) @@ -126,6 +134,8 @@ const adsConversionRulesPublicRouter = { method: "PATCH", path: "/v1/ads/conversion-rules/{id}/status", summary: "Enable or disable an Ads conversion rule", + description: + "Toggles whether a conversion rule is active without changing its other fields.", tags: ["Ads"], }) .input(adsConversionRuleIdParams.and(toggleAdsConversionRulePublicRequest)) @@ -143,6 +153,8 @@ const adsConversionRulesPublicRouter = { method: "DELETE", path: "/v1/ads/conversion-rules/{id}", summary: "Delete an Ads conversion rule", + description: + "Permanently deletes a conversion rule. Use `ads.listRules` to find its id first.", successStatus: 204, tags: ["Ads"], }) @@ -162,6 +174,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/funnel", summary: "Get ad conversion funnel", + description: + "Returns aggregate CTWA/CTM/CTID conversion funnel counts (conversations, leads, purchases, revenue) for one ad. Use `ads.getFunnelTimeseries` for a daily breakdown instead.", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -179,6 +193,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/funnel/timeseries", summary: "Get daily ad conversion funnel", + description: + "Returns the same conversion funnel as `ads.getFunnel`, bucketed per day for charting a trend.", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -196,6 +212,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/capi-delivery", summary: "Get Conversions API delivery", + description: + "Returns the Conversions API delivery status breakdown (sent, pending, failed, skipped) for one ad's events.", tags: ["Ads"], }) .input(getCtwaFunnelPublicRequest) @@ -213,6 +231,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/conversions/export", summary: "Export conversion rows", + description: + "Returns cursor-paginated conversion/lead/purchase rows for export, contact-level. A workspace token sees unmasked contact data.", tags: ["Ads"], }) .input(listAdsConversionExportRowsPublicRequest) @@ -244,6 +264,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/{channel}/ad-accounts", summary: "List channel ad accounts", + description: + "Lists ad accounts connected for a channel — every connected integration's ads connection plus the workspace-wide fallback, deduped. Pass `integrationId` to scope to one integration's own connection.", tags: ["Ads"], }) .input( @@ -269,6 +291,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/analytics/overview", summary: "Get ad analytics overview", + description: + "Returns merged ads analytics (funnel plus channel spend/ROAS/CPM) for one ad, channel, account, and date range. Requires a connected ads account; use `ads.getFunnel` for a DB-only funnel without spend data.", tags: ["Ads"], }) .input(adsAnalyticsPublicRequest) @@ -286,6 +310,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/analytics/timeseries", summary: "Get daily ad analytics", + description: + "Returns the same merged ads analytics as `ads.getAnalyticsOverview`, bucketed per day for charting a trend.", tags: ["Ads"], }) .input(adsAnalyticsPublicRequest) @@ -303,6 +329,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/conversions/{id}", summary: "Get a single Ads conversion event", + description: + "Returns one conversion event's full detail. Use `ads.listConversionExportRows` to find its id first.", tags: ["Ads"], }) .input(adsConversionEventIdParams) @@ -320,6 +348,8 @@ const adsAnalyticsPublicRouter = { method: "GET", path: "/v1/ads/custom-audiences", summary: "List custom audiences", + description: + "Cached from the connected ad account for a fixed period. Use `ads.listChannelAdAccounts` to find `adAccountId` first.", tags: ["Ads"], }) .input(listCustomAudiencesPublicRequest) @@ -342,6 +372,8 @@ const adsAnalyticsPublicRouter = { method: "POST", path: "/v1/ads/retarget-audiences", summary: "Sync retarget audience", + description: + "Queues an async sync of a retargeting audience to the ad platform. Returns immediately; the sync runs in the background.", successStatus: 202, tags: ["Ads"], }) diff --git a/apps/builder/src/features/ads/schema/public.ts b/apps/builder/src/features/ads/schema/public.ts index 592f67bcdc..1727af6bca 100644 --- a/apps/builder/src/features/ads/schema/public.ts +++ b/apps/builder/src/features/ads/schema/public.ts @@ -44,12 +44,16 @@ export const adsConversionRulePublicResource = adsConversionRuleResource.omit({ }) export const adsConversionRuleIdParams = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Ads conversion rule id. Get it from `ads.listRules`.", + ), }) export const listAdsConversionRulesPublicRequest = withPublicPaging( z.object({ - channel: adsConversionChannelSchema.optional(), + channel: adsConversionChannelSchema + .optional() + .describe("Restrict to rules on this channel."), }), ) @@ -69,8 +73,12 @@ export const MAX_ADS_PUBLIC_RANGE_DAYS = 366 const MS_PER_DAY = 24 * 60 * 60 * 1000 const dateRangeShape = z.object({ - since: z.coerce.date(), - until: z.coerce.date(), + since: z.coerce + .date() + .describe("ISO 8601 start of the date range (inclusive)."), + until: z.coerce + .date() + .describe("ISO 8601 end of the date range (inclusive)."), }) // Mirrors `withOrderedDateRange` in @@ -97,12 +105,28 @@ const withPublicDateRange = ( ) const ctwaFunnelPublicShape = dateRangeShape.extend({ - integrationWhatsappId: zodBigintAsString().optional(), - channel: adsConversionChannelSchema.optional(), - integrationMessengerId: zodBigintAsString().optional(), - integrationInstagramId: zodBigintAsString().optional(), - allChannels: z.boolean().optional(), - timezone: z.string().optional(), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe("Restrict to this WhatsApp integration."), + channel: adsConversionChannelSchema + .optional() + .describe("Restrict to this channel."), + integrationMessengerId: zodBigintAsString() + .optional() + .describe("Restrict to this Messenger integration."), + integrationInstagramId: zodBigintAsString() + .optional() + .describe("Restrict to this Instagram integration."), + allChannels: z + .boolean() + .optional() + .describe( + "Aggregate across every channel instead of one. Mutually exclusive with channel/integration filters.", + ), + timezone: z + .string() + .optional() + .describe("IANA timezone used to bucket results."), }) // Shared by the funnel and export public requests so the two endpoints @@ -148,15 +172,46 @@ export const getCtwaFunnelPublicRequest = withPublicDateRange( }) const adsConversionExportPublicShape = dateRangeShape.extend({ - segment: adsConversionExportSegments, - adId: z.string().trim().min(1).nullable().optional(), - integrationWhatsappId: zodBigintAsString().optional(), - channel: adsConversionChannelSchema.optional(), - integrationMessengerId: zodBigintAsString().optional(), - integrationInstagramId: zodBigintAsString().optional(), - allChannels: z.boolean().optional(), - afterId: zodBigintAsString().optional(), - limit: z.number().int().positive().max(1000).default(500), + segment: adsConversionExportSegments.describe( + "Conversion funnel stage to export rows for.", + ), + adId: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("Restrict to this ad id."), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe("Restrict to this WhatsApp integration."), + channel: adsConversionChannelSchema + .optional() + .describe("Restrict to this channel."), + integrationMessengerId: zodBigintAsString() + .optional() + .describe("Restrict to this Messenger integration."), + integrationInstagramId: zodBigintAsString() + .optional() + .describe("Restrict to this Instagram integration."), + allChannels: z + .boolean() + .optional() + .describe( + "Aggregate across every channel instead of one. Mutually exclusive with channel/integration filters.", + ), + afterId: zodBigintAsString() + .optional() + .describe( + "Cursor: id of the last row from the previous page. Omit for the first page.", + ), + limit: z + .number() + .int() + .positive() + .max(1000) + .default(500) + .describe("Maximum rows to return, up to 1000."), }) export const listAdsConversionExportRowsPublicRequest = withPublicDateRange( @@ -204,11 +259,17 @@ export const listAdsConversionExportRowsPublicResponse = z.object({ // ───────────────────────────────────────────────────────────────────────── export const listChannelAdAccountsPublicRequestParams = z.object({ - channel: adsEligibleChannelTypes, + channel: adsEligibleChannelTypes.describe( + "Channel to list connected ad accounts for.", + ), }) export const listChannelAdAccountsPublicRequest = z.object({ - integrationId: zodBigintAsString().optional(), + integrationId: zodBigintAsString() + .optional() + .describe( + "Restrict to this integration's own connection instead of the workspace-wide fallback.", + ), }) export const listChannelAdAccountsPublicResponse = z.object({ @@ -269,21 +330,43 @@ export const capiDeliverySummaryPublicResponse = z.object({ export const adsAnalyticsPublicRequest = z .object({ - from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - tz: z.string().optional(), + from: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe("Start date (YYYY-MM-DD, inclusive)."), + to: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe("End date (YYYY-MM-DD, inclusive)."), + tz: z.string().optional().describe("IANA timezone used to bucket results."), adAccountId: z .string() .regex(/^act_\d+$/) - .optional(), + .optional() + .describe( + "Restrict to this Meta ad account id (act_). Get it from `ads.listChannelAdAccounts`.", + ), // `adsEligibleChannelTypes` here (not `adsConversionChannelSchema`): the // spend fan-out resolves messaging-ads connections, which `facebook` has // none of. - channel: adsEligibleChannelTypes.optional(), - integrationWhatsappId: zodBigintAsString().optional(), - integrationMessengerId: zodBigintAsString().optional(), - integrationInstagramId: zodBigintAsString().optional(), - allChannels: z.boolean().optional(), + channel: adsEligibleChannelTypes + .optional() + .describe("Restrict to this channel."), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe("Restrict to this WhatsApp integration."), + integrationMessengerId: zodBigintAsString() + .optional() + .describe("Restrict to this Messenger integration."), + integrationInstagramId: zodBigintAsString() + .optional() + .describe("Restrict to this Instagram integration."), + allChannels: z + .boolean() + .optional() + .describe( + "Aggregate across every channel instead of one. Mutually exclusive with channel/integration filters.", + ), }) .refine( (input) => @@ -364,7 +447,9 @@ export const adsAnalyticsTimeseriesPublicResponse = z.object({ // ───────────────────────────────────────────────────────────────────────── export const adsConversionEventIdParams = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Ads conversion event id. Get it from `ads.listConversionExportRows`.", + ), }) // `workspaceId` omitted — the leak sweep in public-spec-operations.test.ts @@ -409,7 +494,13 @@ export const adsConversionEventPublicResource = z.object({ // ───────────────────────────────────────────────────────────────────────── export const listCustomAudiencesPublicRequest = z.object({ - adAccountId: z.string().trim().min(1), + adAccountId: z + .string() + .trim() + .min(1) + .describe( + "Meta ad account id (act_). Get it from `ads.listChannelAdAccounts`.", + ), }) export const listCustomAudiencesPublicResponse = z.object({ diff --git a/packages/business/src/ads-conversion/schema.ts b/packages/business/src/ads-conversion/schema.ts index 19ff869b87..20b994da97 100644 --- a/packages/business/src/ads-conversion/schema.ts +++ b/packages/business/src/ads-conversion/schema.ts @@ -13,19 +13,27 @@ const nonEmptyStringArray = z.array(z.string().trim().min(1)).min(1) export const adsConversionRuleTriggerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("templateSent"), - templateIds: nonEmptyStringArray, + templateIds: nonEmptyStringArray.describe( + "Template ids that trigger this rule when sent.", + ), }), z.object({ type: z.literal("tagApplied"), - tagIds: nonEmptyStringArray, + tagIds: nonEmptyStringArray.describe( + "Tag ids that trigger this rule when applied.", + ), }), z.object({ type: z.literal("keywordMatched"), - automatedResponseIds: nonEmptyStringArray, + automatedResponseIds: nonEmptyStringArray.describe( + "Keyword automation ids that trigger this rule when matched.", + ), }), z.object({ type: z.literal("contactReplied"), - firstReplyOnly: z.boolean(), + firstReplyOnly: z + .boolean() + .describe("Only trigger on the contact's first reply, not every reply."), }), ]) export type AdsConversionRuleTrigger = z.infer< @@ -51,7 +59,9 @@ export type AdsConversionRuleResource = z.infer< export const listAdsConversionRulesInput = z.object({ workspaceId: zodBigintAsString(), - channel: adsConversionChannelSchema.optional(), + channel: adsConversionChannelSchema + .optional() + .describe("Restrict to rules on this channel."), }) export type ListAdsConversionRulesInput = z.infer< typeof listAdsConversionRulesInput @@ -59,18 +69,46 @@ export type ListAdsConversionRulesInput = z.infer< export const createAdsConversionRuleInput = z.object({ workspaceId: zodBigintAsString(), - channel: adsConversionChannelSchema, - integrationWhatsappId: zodBigintAsString().nullable().optional(), - integrationFacebookAdsId: zodBigintAsString().nullable().optional(), + channel: adsConversionChannelSchema.describe("Channel this rule applies to."), + integrationWhatsappId: zodBigintAsString() + .nullable() + .optional() + .describe("Restrict to this WhatsApp integration."), + integrationFacebookAdsId: zodBigintAsString() + .nullable() + .optional() + .describe("Restrict to this Facebook Ads integration."), // Messenger/Instagram FKs (Phase 2 generalization) — mirrors // AdsConversionRule's per-channel FK columns (Phase 1 schema). - integrationMessengerId: zodBigintAsString().nullable().optional(), - integrationInstagramId: zodBigintAsString().nullable().optional(), - adAccountId: z.string().trim().min(1).nullable().optional(), - eventType: adsConversionEventTypeSchema, - trigger: adsConversionRuleTriggerSchema, - markAs: z.string().trim().min(1).nullable().optional(), - enabled: z.boolean().optional(), + integrationMessengerId: zodBigintAsString() + .nullable() + .optional() + .describe("Restrict to this Messenger integration."), + integrationInstagramId: zodBigintAsString() + .nullable() + .optional() + .describe("Restrict to this Instagram integration."), + adAccountId: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("Meta ad account id (act_) to report conversions to."), + eventType: adsConversionEventTypeSchema.describe( + "Conversion event type this rule reports.", + ), + trigger: adsConversionRuleTriggerSchema.describe( + "Workspace event that fires this rule.", + ), + markAs: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("Label recorded on the conversion event for this rule."), + enabled: z.boolean().optional().describe("Whether the rule is active."), }) export type CreateAdsConversionRuleInput = z.infer< typeof createAdsConversionRuleInput @@ -79,7 +117,7 @@ export type CreateAdsConversionRuleInput = z.infer< export const updateAdsConversionRuleInput = createAdsConversionRuleInput .partial() .extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe("Ads conversion rule id."), workspaceId: zodBigintAsString(), }) export type UpdateAdsConversionRuleInput = z.infer< @@ -89,7 +127,7 @@ export type UpdateAdsConversionRuleInput = z.infer< export const toggleAdsConversionRuleInput = z.object({ id: zodBigintAsString(), workspaceId: zodBigintAsString(), - enabled: z.boolean(), + enabled: z.boolean().describe("Whether the rule should be active."), }) export type ToggleAdsConversionRuleInput = z.infer< typeof toggleAdsConversionRuleInput diff --git a/packages/business/src/ads-retarget/service.ts b/packages/business/src/ads-retarget/service.ts index 839be7ddcc..3c665fa27a 100644 --- a/packages/business/src/ads-retarget/service.ts +++ b/packages/business/src/ads-retarget/service.ts @@ -22,17 +22,58 @@ function sanitizeJobIdPart(value: string): string { */ export const startRetargetAudienceSyncShape = z.object({ workspaceId: z.string(), - segment: adsConversionExportSegments, - adId: z.string().trim().min(1).nullable().optional(), - channel: adsConversionChannelSchema.optional(), - integrationWhatsappId: z.string().optional(), - integrationMessengerId: z.string().optional(), - integrationInstagramId: z.string().optional(), - since: z.coerce.date(), - until: z.coerce.date(), - adAccountId: z.string().trim().min(1), - audienceName: z.string().trim().min(1).optional(), - customAudienceId: z.string().trim().min(1).optional(), + segment: adsConversionExportSegments.describe( + "Conversion funnel stage to build the audience from.", + ), + adId: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("Restrict to this ad id."), + channel: adsConversionChannelSchema + .optional() + .describe("Restrict to this channel."), + integrationWhatsappId: z + .string() + .optional() + .describe("Restrict to this WhatsApp integration."), + integrationMessengerId: z + .string() + .optional() + .describe("Restrict to this Messenger integration."), + integrationInstagramId: z + .string() + .optional() + .describe("Restrict to this Instagram integration."), + since: z.coerce + .date() + .describe("ISO 8601 start of the date range (inclusive)."), + until: z.coerce + .date() + .describe("ISO 8601 end of the date range (inclusive)."), + adAccountId: z + .string() + .trim() + .min(1) + .describe("Meta ad account id (act_) to sync the audience to."), + audienceName: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Name for a new custom audience. Provide this or customAudienceId, not both.", + ), + customAudienceId: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Existing custom audience id to sync into. Provide this or audienceName, not both.", + ), }) export const startRetargetAudienceSyncInput = From a11259b8604c48236cd0db700f2a1467b907e0b1 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 12:13:57 +0700 Subject: [PATCH 29/38] feat(api): complete facebookLeadAds public API description coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS1 batch 5 finale: all 7 facebookLeadAds.* operations gain description, tags, house-style summaries, and .describe() on every top-level input field. Removes "facebookLeadAds." from DESCRIPTION_BACKLOG, completing WS1 batch 5 (ads, ads-campaign, facebookLeadAds) in full — 38 operations. --- .../__tests__/public-spec-operations.test.ts | 1 - .../facebook-lead-ad-automation/api/public.ts | 14 ++++++ .../schema/action.ts | 46 +++++++++++++++---- .../schema/public.ts | 26 ++++++++--- 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 9ecceda658..04559b070d 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -68,7 +68,6 @@ const DESCRIPTION_BACKLOG = new Set([ "customFields.", "dynamicImages.", "emailTopics.", - "facebookLeadAds.", "fbComments.", "folders.", "igComments.", diff --git a/apps/builder/src/features/facebook-lead-ad-automation/api/public.ts b/apps/builder/src/features/facebook-lead-ad-automation/api/public.ts index ac2c45893c..428ac043ed 100644 --- a/apps/builder/src/features/facebook-lead-ad-automation/api/public.ts +++ b/apps/builder/src/features/facebook-lead-ad-automation/api/public.ts @@ -37,6 +37,8 @@ export const facebookLeadAdsPublicRouter = { method: "GET", path: "/v1/facebook-lead-ads", summary: "List Facebook Lead Ads automations", + description: + "Use this to find automation ids before inspecting one with `facebookLeadAds.get` or changing one with `facebookLeadAds.update`. Returns automations configured in this workspace.", tags: ["Facebook Lead Ads"], }) .input(listFacebookLeadAdsPublicRequest) @@ -55,6 +57,8 @@ export const facebookLeadAdsPublicRouter = { method: "GET", path: "/v1/facebook-lead-ads/{id}", summary: "Get a Facebook Lead Ads automation", + description: + "Returns one automation's page, form, and reply configuration. Use `facebookLeadAds.list` to find its id first.", tags: ["Facebook Lead Ads"], }) .input(getFacebookLeadAdPublicRequest) @@ -76,6 +80,8 @@ export const facebookLeadAdsPublicRouter = { method: "POST", path: "/v1/facebook-lead-ads", summary: "Create a Facebook Lead Ads automation", + description: + "Subscribes a page to lead-form webhooks and configures the reply sent to new leads. Use `facebookLeadAds.listPages`/`facebookLeadAds.listForms` first to resolve valid page/form ids.", successStatus: 201, tags: ["Facebook Lead Ads"], }) @@ -96,6 +102,8 @@ export const facebookLeadAdsPublicRouter = { method: "PUT", path: "/v1/facebook-lead-ads/{id}", summary: "Update a Facebook Lead Ads automation", + description: + "Changes an existing automation's configuration. Call `facebookLeadAds.get` to inspect current values first.", tags: ["Facebook Lead Ads"], }) .input(updateFacebookLeadAdPublicRequest) @@ -118,6 +126,8 @@ export const facebookLeadAdsPublicRouter = { method: "DELETE", path: "/v1/facebook-lead-ads/{id}", summary: "Delete a Facebook Lead Ads automation", + description: + "Permanently deletes an automation and stops replying to new leads for it. Use `facebookLeadAds.list` to find its id first.", successStatus: 204, tags: ["Facebook Lead Ads"], }) @@ -135,6 +145,8 @@ export const facebookLeadAdsPublicRouter = { method: "GET", path: "/v1/facebook-lead-ads/pages", summary: "List Messenger pages eligible for Lead Ads", + description: + "Returns pages connected to this workspace that can be automated. Use `facebookLeadAds.listForms` to see a page's lead forms next.", tags: ["Facebook Lead Ads"], }) .output(listFacebookLeadAdsPagesPublicResponse) @@ -148,6 +160,8 @@ export const facebookLeadAdsPublicRouter = { method: "GET", path: "/v1/facebook-lead-ads/forms", summary: "List a page's lead forms", + description: + "Returns lead-generation forms configured on a Facebook page. Use `facebookLeadAds.listPages` to find `pageId` first.", tags: ["Facebook Lead Ads"], }) .input(listFacebookLeadAdsFormsPublicRequest) diff --git a/apps/builder/src/features/facebook-lead-ad-automation/schema/action.ts b/apps/builder/src/features/facebook-lead-ad-automation/schema/action.ts index 1592f15c26..c2918a903d 100644 --- a/apps/builder/src/features/facebook-lead-ad-automation/schema/action.ts +++ b/apps/builder/src/features/facebook-lead-ad-automation/schema/action.ts @@ -8,23 +8,49 @@ const nullableFlowId = z .optional() export const createFacebookLeadAdAutomationRequest = z.object({ - name: z.string().min(1).max(100), - pageId: z.string().min(1), - pageName: z.string().nullable().optional(), + name: z.string().min(1).max(100).describe("Automation name."), + pageId: z + .string() + .min(1) + .describe("Facebook page id. Get it from `facebookLeadAds.listPages`."), + pageName: z + .string() + .nullable() + .optional() + .describe("Display name of the page."), // "*" (ALL_FORMS_ID) means every lead form on the page. - formId: z.string().min(1), - formName: z.string().nullable().optional(), - fieldMapping: z.array(facebookLeadFieldMappingSchema).default([]), - flowId: nullableFlowId, + formId: z + .string() + .min(1) + .describe( + 'Lead form id, or "*" for every form on the page. Get it from `facebookLeadAds.listForms`.', + ), + formName: z + .string() + .nullable() + .optional() + .describe("Display name of the lead form."), + fieldMapping: z + .array(facebookLeadFieldMappingSchema) + .default([]) + .describe("Mappings from lead form fields to contact/custom fields."), + flowId: nullableFlowId.describe( + "Flow id to start when a new lead arrives, or null to send no flow.", + ), }) export type CreateFacebookLeadAdAutomationRequest = z.infer< typeof createFacebookLeadAdAutomationRequest > export const updateFacebookLeadAdAutomationRequest = z.object({ - name: z.string().min(1).max(100).optional(), - fieldMapping: z.array(facebookLeadFieldMappingSchema).optional(), - flowId: nullableFlowId, + name: z.string().min(1).max(100).optional().describe("New automation name."), + fieldMapping: z + .array(facebookLeadFieldMappingSchema) + .optional() + .describe("Mappings from lead form fields to contact/custom fields."), + flowId: nullableFlowId.describe( + "Flow id to start when a new lead arrives, or null to send no flow.", + ), }) export type UpdateFacebookLeadAdAutomationRequest = z.infer< typeof updateFacebookLeadAdAutomationRequest diff --git a/apps/builder/src/features/facebook-lead-ad-automation/schema/public.ts b/apps/builder/src/features/facebook-lead-ad-automation/schema/public.ts index 1aa4978ced..1f2028aaf9 100644 --- a/apps/builder/src/features/facebook-lead-ad-automation/schema/public.ts +++ b/apps/builder/src/features/facebook-lead-ad-automation/schema/public.ts @@ -8,7 +8,12 @@ import { import { facebookLeadAdsAutomationResource } from "./resource" export const listFacebookLeadAdsPublicRequest = publicListRequest.extend({ - keyword: z.string().optional(), + keyword: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the automation's page/form name.", + ), sort: z .array( z.object({ @@ -16,7 +21,8 @@ export const listFacebookLeadAdsPublicRequest = publicListRequest.extend({ desc: z.boolean(), }), ) - .optional(), + .optional() + .describe("Sort order as [{ id, desc }] column/direction pairs."), }) const facebookLeadAdPublicItem = facebookLeadAdsAutomationResource @@ -32,7 +38,9 @@ export const listFacebookLeadAdsPublicResponse = publicListResponse( ) export const getFacebookLeadAdPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Facebook Lead Ads automation id. Get it from `facebookLeadAds.list`.", + ), }) export const createFacebookLeadAdPublicRequest = @@ -40,11 +48,15 @@ export const createFacebookLeadAdPublicRequest = export const updateFacebookLeadAdPublicRequest = updateFacebookLeadAdAutomationRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Facebook Lead Ads automation id. Get it from `facebookLeadAds.list`.", + ), }) export const deleteFacebookLeadAdPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Facebook Lead Ads automation id. Get it from `facebookLeadAds.list`.", + ), }) export const facebookLeadAdPublicDetailResource = facebookLeadAdPublicItem @@ -59,7 +71,9 @@ export const listFacebookLeadAdsPagesPublicResponse = z.object({ }) export const listFacebookLeadAdsFormsPublicRequest = z.object({ - pageId: z.string(), + pageId: z + .string() + .describe("Facebook page id. Get it from `facebookLeadAds.listPages`."), }) const facebookLeadAdFormQuestion = z.object({ From 93f2917556588e694ca77f4def4ca51018818b87 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 12:15:38 +0700 Subject: [PATCH 30/38] feat(api): complete media library public API description coverage WS1 batch 6 (partial): all 11 mediaLibrary.* operations gain description, tags, and .describe() on every top-level input field. Removes "mediaLibrary." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/media-library/api/public.ts | 22 ++++++ .../features/media-library/schema/public.ts | 70 +++++++++++++------ 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 04559b070d..aecdb1f2da 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -73,7 +73,6 @@ const DESCRIPTION_BACKLOG = new Set([ "igComments.", "igStories.", "integrations.", - "mediaLibrary.", "messengerChannels.", "messengerPersonas.", "minigames.", diff --git a/apps/builder/src/features/media-library/api/public.ts b/apps/builder/src/features/media-library/api/public.ts index 3b1486b338..98f6970b55 100644 --- a/apps/builder/src/features/media-library/api/public.ts +++ b/apps/builder/src/features/media-library/api/public.ts @@ -41,6 +41,8 @@ export const mediaLibraryPublicRouter = { method: "GET", path: "/v1/media-library/folders", summary: "List media library folders", + description: + "Use this to find folder ids before listing its files with `mediaLibrary.listFiles` or moving files into it with `mediaLibrary.moveFiles`. Returns folders in this workspace.", tags, }) .input(listMediaLibraryFoldersPublicRequest) @@ -61,6 +63,8 @@ export const mediaLibraryPublicRouter = { method: "POST", path: "/v1/media-library/folders", summary: "Create a media library folder", + description: + "Adds a folder to organize media library files. Use `mediaLibrary.listFolders` first to avoid duplicating an existing folder.", successStatus: 201, tags, }) @@ -80,6 +84,8 @@ export const mediaLibraryPublicRouter = { method: "PATCH", path: "/v1/media-library/folders/{folderId}", summary: "Rename a media library folder", + description: + "Changes a folder's display name without moving its files. Use `mediaLibrary.listFolders` to find its id first.", successStatus: 204, tags, }) @@ -98,6 +104,8 @@ export const mediaLibraryPublicRouter = { method: "DELETE", path: "/v1/media-library/folders/{folderId}", summary: "Delete a media library folder and all its files", + description: + "Permanently deletes a folder and every file inside it, including their storage objects. Use `mediaLibrary.moveFiles` first to preserve files by moving them out.", successStatus: 204, tags, }) @@ -137,6 +145,8 @@ export const mediaLibraryPublicRouter = { method: "GET", path: "/v1/media-library/files", summary: "List media library files", + description: + "Use this to find file ids before inspecting one with `mediaLibrary.getFile` or attaching one to a message. Returns files in this workspace.", tags, }) .input(listMediaLibraryFilesPublicRequest) @@ -155,6 +165,8 @@ export const mediaLibraryPublicRouter = { method: "GET", path: "/v1/media-library/files/{fileId}", summary: "Get a media library file", + description: + "Returns one file's metadata (path, mime type, size). Use `mediaLibrary.listFiles` to find its id first.", tags, }) .input(getMediaLibraryFilePublicRequest) @@ -173,6 +185,8 @@ export const mediaLibraryPublicRouter = { method: "POST", path: "/v1/media-library/files", summary: "Register an uploaded file in the media library", + description: + "Registers a file already uploaded to the storage path from `mediaLibrary.createUploadUrl`, making it appear in `mediaLibrary.listFiles`.", successStatus: 201, tags, }) @@ -192,6 +206,8 @@ export const mediaLibraryPublicRouter = { method: "DELETE", path: "/v1/media-library/files/{fileId}", summary: "Delete a media library file and its storage object", + description: + "Permanently deletes a file's metadata and its underlying storage object. Use `mediaLibrary.listFiles` to find its id first.", successStatus: 204, tags, }) @@ -209,6 +225,8 @@ export const mediaLibraryPublicRouter = { method: "PUT", path: "/v1/media-library/files/{fileId}/favourite", summary: "Set a media library file's favourite status", + description: + "Marks a file as favourited or not, without changing its other metadata.", tags, }) .input(setMediaLibraryFavouritePublicRequest) @@ -228,6 +246,8 @@ export const mediaLibraryPublicRouter = { method: "POST", path: "/v1/media-library/files/{fileId}/access", summary: "Record media file access", + description: + "Records that a file was viewed or used, updating its last-accessed timestamp for sorting/cleanup purposes.", successStatus: 204, tags, }) @@ -245,6 +265,8 @@ export const mediaLibraryPublicRouter = { method: "PATCH", path: "/v1/media-library/files/move", summary: "Move media library files to another folder", + description: + "Moves the given files into a different folder, or to no folder if `folderId` is null.", successStatus: 204, tags, }) diff --git a/apps/builder/src/features/media-library/schema/public.ts b/apps/builder/src/features/media-library/schema/public.ts index f55176f29c..63b82d82d1 100644 --- a/apps/builder/src/features/media-library/schema/public.ts +++ b/apps/builder/src/features/media-library/schema/public.ts @@ -24,21 +24,32 @@ export const mediaLibraryFileListItemPublicResource = mediaLibraryFilePublicResource.extend({ url: z.string() }) export const createMediaLibraryFolderPublicRequest = z.object({ - name: z.string().min(1), + name: z.string().min(1).describe("Folder name."), }) export const renameMediaLibraryFolderPublicRequest = z.object({ - folderId: zodBigintAsString(), - name: z.string().min(1), + folderId: zodBigintAsString().describe( + "Folder id. Get it from `mediaLibrary.listFolders`.", + ), + name: z.string().min(1).describe("New folder name."), }) export const deleteMediaLibraryFolderPublicRequest = z.object({ - folderId: zodBigintAsString(), + folderId: zodBigintAsString().describe( + "Folder id. Get it from `mediaLibrary.listFolders`.", + ), }) export const listMediaLibraryFilesPublicRequest = publicListRequest.extend({ - folderId: zodBigintAsString().nullish(), - search: z.string().optional(), + folderId: zodBigintAsString() + .nullish() + .describe( + "Restrict to files in this folder. Omit for root-level files only.", + ), + search: z + .string() + .optional() + .describe("Case-insensitive substring match against the file name."), filter: z .enum(["all", "recent", "favourite"]) .optional() @@ -52,8 +63,8 @@ export const listMediaLibraryFilesPublicResponse = publicListResponse( ) export const createMediaLibraryUploadUrlPublicRequest = z.object({ - fileName: z.string().min(1), - mimeType: z.string().min(1), + fileName: z.string().min(1).describe("File name for the upload."), + mimeType: z.string().min(1).describe("File MIME type."), }) export const createMediaLibraryUploadUrlPublicResponse = z.object({ @@ -63,31 +74,50 @@ export const createMediaLibraryUploadUrlPublicResponse = z.object({ }) export const getMediaLibraryFilePublicRequest = z.object({ - fileId: zodBigintAsString(), + fileId: zodBigintAsString().describe( + "File id. Get it from `mediaLibrary.listFiles`.", + ), }) export const createMediaLibraryFilePublicRequest = z.object({ - folderId: zodBigintAsString().nullish(), - name: z.string(), - path: z.string(), - mimeType: z.string(), - size: z.number(), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to register the file in, or null for root-level."), + name: z.string().describe("File name."), + path: z + .string() + .describe("Storage path from `mediaLibrary.createUploadUrl`."), + mimeType: z.string().describe("File MIME type."), + size: z.number().describe("File size in bytes."), }) export const deleteMediaLibraryFilePublicRequest = z.object({ - fileId: zodBigintAsString(), + fileId: zodBigintAsString().describe( + "File id. Get it from `mediaLibrary.listFiles`.", + ), }) export const setMediaLibraryFavouritePublicRequest = z.object({ - fileId: zodBigintAsString(), - isFavourite: z.boolean(), + fileId: zodBigintAsString().describe( + "File id. Get it from `mediaLibrary.listFiles`.", + ), + isFavourite: z + .boolean() + .describe("Whether the file should be marked favourited."), }) export const recordMediaLibraryFileAccessPublicRequest = z.object({ - fileId: zodBigintAsString(), + fileId: zodBigintAsString().describe( + "File id. Get it from `mediaLibrary.listFiles`.", + ), }) export const moveMediaLibraryFilesPublicRequest = z.object({ - fileIds: z.array(zodBigintAsString()).min(1), - folderId: zodBigintAsString().nullish(), + fileIds: z + .array(zodBigintAsString()) + .min(1) + .describe("File ids to move. Get them from `mediaLibrary.listFiles`."), + folderId: zodBigintAsString() + .nullish() + .describe("Destination folder id, or null to move to root-level."), }) From d9e01d50363f9527611cdf1d4bb51200e27f8851 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 12:17:37 +0700 Subject: [PATCH 31/38] feat(api): complete dynamic images public API description coverage WS1 batch 6 (partial): all 6 dynamicImages.* operations gain description, tags, and .describe() on every top-level input field. Removes "dynamicImages." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/dynamic-images/api/public.ts | 28 +++++++++++++++++-- .../features/dynamic-images/schema/action.ts | 12 ++++++-- .../features/dynamic-images/schema/public.ts | 17 ++++++++--- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index aecdb1f2da..9df460e088 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -66,7 +66,6 @@ const DESCRIPTION_BACKLOG = new Set([ "contactScans.", "coupons.", "customFields.", - "dynamicImages.", "emailTopics.", "fbComments.", "folders.", diff --git a/apps/builder/src/features/dynamic-images/api/public.ts b/apps/builder/src/features/dynamic-images/api/public.ts index c3bec335fe..f979a7f160 100644 --- a/apps/builder/src/features/dynamic-images/api/public.ts +++ b/apps/builder/src/features/dynamic-images/api/public.ts @@ -49,6 +49,8 @@ export const dynamicImagesPublicRouter = { method: "GET", path: "/v1/dynamic-images", summary: "List dynamic images", + description: + "Use this to find dynamic image ids before inspecting one with `dynamicImages.get` or changing one with `dynamicImages.update`. Returns dynamic images in this workspace.", tags, }) .input(listDynamicImagesPublicRequest) @@ -72,9 +74,17 @@ export const dynamicImagesPublicRouter = { method: "GET", path: "/v1/dynamic-images/{id}", summary: "Get a dynamic image", + description: + "Returns one dynamic image's template and settings. Use `dynamicImages.list` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Dynamic image id. Get it from `dynamicImages.list`.", + ), + }), + ) .output(publicDynamicImageResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -90,6 +100,8 @@ export const dynamicImagesPublicRouter = { method: "POST", path: "/v1/dynamic-images", summary: "Create a dynamic image", + description: + "Adds a dynamically-rendered image template that fills in per-contact data via a `{{user_id}}` URL. Use `dynamicImages.list` first to avoid duplicating an existing one.", successStatus: 201, tags, }) @@ -109,6 +121,8 @@ export const dynamicImagesPublicRouter = { method: "PUT", path: "/v1/dynamic-images/{id}", summary: "Update a dynamic image", + description: + "Changes an existing dynamic image's template or settings. Call `dynamicImages.get` to inspect current values first.", tags, }) .input(updateDynamicImagePublicRequest) @@ -129,10 +143,18 @@ export const dynamicImagesPublicRouter = { method: "DELETE", path: "/v1/dynamic-images/{id}", summary: "Delete a dynamic image", + description: + "Permanently deletes a dynamic image template. Use `dynamicImages.list` to find its id first.", successStatus: 204, tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Dynamic image id. Get it from `dynamicImages.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await dynamicImageService.delete({ @@ -146,6 +168,8 @@ export const dynamicImagesPublicRouter = { method: "PATCH", path: "/v1/dynamic-images/{id}/enabled", summary: "Set whether a dynamic image is enabled", + description: + "Toggles whether a dynamic image is enabled without changing its template or settings.", tags, }) .input(setDynamicImageEnabledPublicRequest) diff --git a/apps/builder/src/features/dynamic-images/schema/action.ts b/apps/builder/src/features/dynamic-images/schema/action.ts index d29a6fb017..bfbc61fa17 100644 --- a/apps/builder/src/features/dynamic-images/schema/action.ts +++ b/apps/builder/src/features/dynamic-images/schema/action.ts @@ -3,9 +3,15 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createDynamicImageRequest = z.object({ - name: z.string().min(1).max(100), - customFieldId: zodBigintAsString().nullish(), - data: dynamicImageDocument, + name: z.string().min(1).max(100).describe("Dynamic image name."), + customFieldId: zodBigintAsString() + .nullish() + .describe( + "Custom field id whose per-contact value personalizes the render.", + ), + data: dynamicImageDocument.describe( + "Image template document (layers, text, positioning).", + ), }) export type CreateDynamicImageRequest = z.infer< typeof createDynamicImageRequest diff --git a/apps/builder/src/features/dynamic-images/schema/public.ts b/apps/builder/src/features/dynamic-images/schema/public.ts index 06d4699024..5f20ecc2c8 100644 --- a/apps/builder/src/features/dynamic-images/schema/public.ts +++ b/apps/builder/src/features/dynamic-images/schema/public.ts @@ -19,7 +19,12 @@ export const publicDynamicImageResource = dynamicImageResource }) export const listDynamicImagesPublicRequest = publicListRequest.extend({ - name: z.string().optional(), + name: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the dynamic image's name.", + ), }) export const listDynamicImagesPublicResponse = publicListResponse( @@ -30,11 +35,15 @@ export const createDynamicImagePublicRequest = createDynamicImageRequest export const updateDynamicImagePublicRequest = updateDynamicImageRequest.extend( { - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Dynamic image id. Get it from `dynamicImages.list`.", + ), }, ) export const setDynamicImageEnabledPublicRequest = z.object({ - id: zodBigintAsString(), - enabled: z.boolean(), + id: zodBigintAsString().describe( + "Dynamic image id. Get it from `dynamicImages.list`.", + ), + enabled: z.boolean().describe("Whether the dynamic image should be enabled."), }) From cc72ab49c65dddf12782545cc76fc6517ff80f90 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 12:19:09 +0700 Subject: [PATCH 32/38] feat(api): complete QR codes public API description coverage WS1 batch 6 (partial): all 5 qrCodes.* operations gain description, tags, and .describe() on every top-level input field. Removes "qrCodes." from DESCRIPTION_BACKLOG. --- .../builder/__tests__/public-spec-operations.test.ts | 1 - apps/builder/src/features/qr-codes/api/public.ts | 10 ++++++++++ apps/builder/src/features/qr-codes/schema/action.ts | 10 +++++++--- apps/builder/src/features/qr-codes/schema/public.ts | 12 +++++++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 9df460e088..55df882e58 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -77,7 +77,6 @@ const DESCRIPTION_BACKLOG = new Set([ "minigames.", "productCategories.", "products.", - "qrCodes.", "questionnaires.", "reflinks.", "smtpIntegrations.", diff --git a/apps/builder/src/features/qr-codes/api/public.ts b/apps/builder/src/features/qr-codes/api/public.ts index 75bd488d2b..cec94faac1 100644 --- a/apps/builder/src/features/qr-codes/api/public.ts +++ b/apps/builder/src/features/qr-codes/api/public.ts @@ -28,6 +28,8 @@ export const qrCodesPublicRouter = { method: "GET", path: "/v1/qr-codes", summary: "List QR codes", + description: + "Use this to find QR code ids before inspecting one with `qrCodes.get` or changing one with `qrCodes.update`. Returns QR codes in this workspace.", tags, }) .input(publicListQrCodesRequest) @@ -46,6 +48,8 @@ export const qrCodesPublicRouter = { method: "GET", path: "/v1/qr-codes/{id}", summary: "Get a QR code", + description: + "Returns one QR code's target and settings. Use `qrCodes.list` to find its id first.", tags, }) .input(publicGetQrCodeRequest) @@ -64,6 +68,8 @@ export const qrCodesPublicRouter = { method: "POST", path: "/v1/qr-codes", summary: "Create a QR code", + description: + "Adds a scannable QR code. Use `qrCodes.list` first to avoid duplicating an existing name.", successStatus: 201, tags, }) @@ -84,6 +90,8 @@ export const qrCodesPublicRouter = { method: "PUT", path: "/v1/qr-codes/{id}", summary: "Update a QR code", + description: + "Changes an existing QR code's target or settings. Call `qrCodes.get` to inspect current values first.", tags, }) .input(publicUpdateQrCodeRequest) @@ -104,6 +112,8 @@ export const qrCodesPublicRouter = { method: "DELETE", path: "/v1/qr-codes/{id}", summary: "Delete a QR code", + description: + "Permanently deletes a QR code. Use `qrCodes.list` to find its id first.", successStatus: 204, tags, }) diff --git a/apps/builder/src/features/qr-codes/schema/action.ts b/apps/builder/src/features/qr-codes/schema/action.ts index d9aabbeeea..5fb85af526 100644 --- a/apps/builder/src/features/qr-codes/schema/action.ts +++ b/apps/builder/src/features/qr-codes/schema/action.ts @@ -9,14 +9,18 @@ export const createQrCodeRequest = z.object({ .string() .min(1) .max(50) - .refine((value) => QR_CODE_NAME_REGEX.test(value)), - flowId: zodBigintAsString(), + .refine((value) => QR_CODE_NAME_REGEX.test(value)) + .describe("QR code name, alphanumeric only."), + flowId: zodBigintAsString().describe( + "Flow to trigger when the QR code is scanned. Get it from `flows.list`.", + ), size: z.coerce .number() .int() .min(QR_CODE_SIZE.MIN) .max(QR_CODE_SIZE.MAX) - .default(QR_CODE_SIZE.DEFAULT), + .default(QR_CODE_SIZE.DEFAULT) + .describe("Rendered image size in pixels."), }) export type CreateQrCodeRequest = z.infer diff --git a/apps/builder/src/features/qr-codes/schema/public.ts b/apps/builder/src/features/qr-codes/schema/public.ts index 02bae156cb..affa9ef129 100644 --- a/apps/builder/src/features/qr-codes/schema/public.ts +++ b/apps/builder/src/features/qr-codes/schema/public.ts @@ -4,10 +4,15 @@ import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { createQrCodeRequest, updateQrCodeRequest } from "./action" import { qrCodeResource } from "./resource" -const qrCodeId = zodBigintAsString() +const qrCodeId = zodBigintAsString().describe( + "QR code id. Get it from `qrCodes.list`.", +) export const publicListQrCodesRequest = publicListRequest.extend({ - keyword: z.string().optional(), + keyword: z + .string() + .optional() + .describe("Case-insensitive substring match against the QR code's name."), sort: z .array( z.object({ @@ -15,7 +20,8 @@ export const publicListQrCodesRequest = publicListRequest.extend({ desc: z.boolean(), }), ) - .optional(), + .optional() + .describe("Sort order as [{ id, desc }] column/direction pairs."), }) const qrCodePublicItem = qrCodeResource.omit({ workspaceId: true }).and( From 1d711b437ee2afe1f6b0851189057a6bf1d1c902 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 13:57:39 +0700 Subject: [PATCH 33/38] feat(api): complete minigames public API description coverage WS1 batch 6 (partial): all 10 minigames.* operations gain description, tags, and .describe() on every top-level input field. Also describes the shared bulkUpdateIdsRequest.ids field at its source (used by minigames.deleteMany). Removes "minigames." from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/common/schema/index.ts | 2 +- .../src/features/minigames/api/public.ts | 36 ++++++++++++++- .../src/features/minigames/schema/action.ts | 26 ++++++++--- .../src/features/minigames/schema/public.ts | 44 +++++++++++++++---- 5 files changed, 89 insertions(+), 20 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 55df882e58..1f432c9642 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -74,7 +74,6 @@ const DESCRIPTION_BACKLOG = new Set([ "integrations.", "messengerChannels.", "messengerPersonas.", - "minigames.", "productCategories.", "products.", "questionnaires.", diff --git a/apps/builder/src/features/common/schema/index.ts b/apps/builder/src/features/common/schema/index.ts index e9cf2f0a5f..84d5c91895 100644 --- a/apps/builder/src/features/common/schema/index.ts +++ b/apps/builder/src/features/common/schema/index.ts @@ -13,7 +13,7 @@ export const workspaceIdAndIdRequestParams: [z.ZodString, z.ZodString] = [ export type WorkspaceIdAndIdRequestParams = [string, string] export const bulkUpdateIdsRequest = z.object({ - ids: z.array(zodBigintAsString()), + ids: z.array(zodBigintAsString()).describe("Ids of the records to act on."), }) export type BulkUpdateIdsRequest = z.infer diff --git a/apps/builder/src/features/minigames/api/public.ts b/apps/builder/src/features/minigames/api/public.ts index bb7dd19e7f..774ed42578 100644 --- a/apps/builder/src/features/minigames/api/public.ts +++ b/apps/builder/src/features/minigames/api/public.ts @@ -37,6 +37,8 @@ export const minigamesPublicRouter = { method: "GET", path: "/v1/minigames", summary: "List minigames", + description: + "Use this to find minigame ids before inspecting one with `minigames.get` or listing its plays with `minigames.listPlays`. Returns minigames in this workspace.", tags, }) .input(listMinigamesPublicRequest) @@ -56,9 +58,17 @@ export const minigamesPublicRouter = { method: "GET", path: "/v1/minigames/{id}", summary: "Get a minigame", + description: + "Returns one minigame's configuration and prizes. Use `minigames.list` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + }), + ) .output(minigamePublicResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -74,6 +84,8 @@ export const minigamesPublicRouter = { method: "POST", path: "/v1/minigames", summary: "Create a minigame", + description: + "Adds a minigame (e.g. jackpot) that contacts can play through a flow step or public link. Use `minigames.list` first to avoid duplicating an existing one.", successStatus: 201, tags, }) @@ -93,6 +105,8 @@ export const minigamesPublicRouter = { method: "PUT", path: "/v1/minigames/{id}", summary: "Update a minigame", + description: + "Replaces an existing minigame's full configuration. Call `minigames.get` to inspect current values first.", tags, }) .input(updateMinigamePublicRequest) @@ -113,6 +127,8 @@ export const minigamesPublicRouter = { method: "PATCH", path: "/v1/minigames/{id}", summary: "Partially update a minigame", + description: + "Changes only the given fields of an existing minigame, leaving the rest unchanged. Call `minigames.get` to inspect current values first.", tags, }) .input(patchMinigamePublicRequest) @@ -132,10 +148,18 @@ export const minigamesPublicRouter = { method: "DELETE", path: "/v1/minigames/{id}", summary: "Delete a minigame", + description: + "Permanently deletes a minigame and its configuration. Use `minigames.list` to find its id first.", successStatus: 204, tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await minigameService.delete({ @@ -149,6 +173,8 @@ export const minigamesPublicRouter = { method: "POST", path: "/v1/minigames/bulk-delete", summary: "Delete multiple minigames", + description: + "Permanently deletes several minigames in one call. Use `minigames.list` to find their ids first.", successStatus: 204, tags, }) @@ -166,6 +192,8 @@ export const minigamesPublicRouter = { method: "PATCH", path: "/v1/minigames/{id}/enabled", summary: "Enable or disable a minigame", + description: + "Toggles whether a minigame is playable without changing its configuration.", tags, }) .input(setMinigameEnabledPublicRequest) @@ -184,6 +212,8 @@ export const minigamesPublicRouter = { method: "GET", path: "/v1/minigames/{id}/plays", summary: "List a contact's minigame play records", + description: + "Returns every play a specific contact made on a minigame, including prizes won. Use `minigames.list` to find the minigame id first.", tags, }) .input(listMinigamePlaysPublicRequest) @@ -203,6 +233,8 @@ export const minigamesPublicRouter = { method: "GET", path: "/v1/minigames/{id}/players", summary: "List a minigame's players", + description: + "Returns contacts who have played a minigame, with their play counts and prizes. Use `minigames.list` to find the minigame id first.", tags, }) .input(listMinigamePlayersPublicRequest) diff --git a/apps/builder/src/features/minigames/schema/action.ts b/apps/builder/src/features/minigames/schema/action.ts index 316f3d7cd0..acf4887be0 100644 --- a/apps/builder/src/features/minigames/schema/action.ts +++ b/apps/builder/src/features/minigames/schema/action.ts @@ -11,13 +11,25 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createMinigameRequest = z.object({ - type: minigameTypes, - generalSettings: minigameGeneralSettingsSchema, - appearance: minigameAppearanceSchema, - playerSettings: minigamePlayerSettingsSchema, - prizeSettings: minigamePrizeSettingsSchema, - winningMessageSettings: minigameWinningMessageSettingsSchema, - nonWinningMessageSettings: minigameNonWinningMessageSettingsSchema, + type: minigameTypes.describe("Minigame type, e.g. `jackpot`."), + generalSettings: minigameGeneralSettingsSchema.describe( + "Name, status, and other top-level configuration.", + ), + appearance: minigameAppearanceSchema.describe( + "Visual theme and branding shown to players.", + ), + playerSettings: minigamePlayerSettingsSchema.describe( + "Rules for who can play and how often.", + ), + prizeSettings: minigamePrizeSettingsSchema.describe( + "Prizes and their odds/quantities.", + ), + winningMessageSettings: minigameWinningMessageSettingsSchema.describe( + "Message shown to a player who wins a prize.", + ), + nonWinningMessageSettings: minigameNonWinningMessageSettingsSchema.describe( + "Message shown to a player who does not win.", + ), }) export type CreateMinigameRequest = z.infer diff --git a/apps/builder/src/features/minigames/schema/public.ts b/apps/builder/src/features/minigames/schema/public.ts index f6a3842105..e520636953 100644 --- a/apps/builder/src/features/minigames/schema/public.ts +++ b/apps/builder/src/features/minigames/schema/public.ts @@ -5,7 +5,12 @@ import { createMinigameRequest, updateMinigameRequest } from "./action" import { minigameResource } from "./resource" export const listMinigamesPublicRequest = publicListRequest.extend({ - name: z.string().trim().min(1).optional(), + name: z + .string() + .trim() + .min(1) + .optional() + .describe("Case-insensitive substring match against the minigame's name."), }) export const minigamePublicResource = minigameResource.omit({ @@ -19,12 +24,18 @@ export const listMinigamesPublicResponse = publicListResponse( export const createMinigamePublicRequest = createMinigameRequest export const updateMinigamePublicRequest = updateMinigameRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), }) export const patchMinigamePublicRequest = createMinigameRequest .partial() - .extend({ id: zodBigintAsString() }) + .extend({ + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + }) .refine( (data) => Object.entries(data).some( @@ -34,18 +45,33 @@ export const patchMinigamePublicRequest = createMinigameRequest ) export const setMinigameEnabledPublicRequest = z.object({ - id: zodBigintAsString(), - enabled: z.boolean(), + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + enabled: z.boolean().describe("Whether the minigame should be playable."), }) export const listMinigamePlaysPublicRequest = z.object({ - id: zodBigintAsString(), - contactId: zodBigintAsString(), + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + contactId: zodBigintAsString().describe( + "Contact id. Get it from `contacts.list`.", + ), }) export const listMinigamePlayersPublicRequest = publicListRequest.extend({ - id: zodBigintAsString(), - name: z.string().trim().min(1).optional(), + id: zodBigintAsString().describe( + "Minigame id. Get it from `minigames.list`.", + ), + name: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Case-insensitive substring match against the player's contact name.", + ), }) export const minigamePlayerResource = z.object({ From 4a692ba281a44d6dbbf0d7a11cbf49bb5c602de2 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 14:00:09 +0700 Subject: [PATCH 34/38] feat(api): complete coupons public API description coverage WS1 batch 6 (final): all 11 coupons.* operations gain description, tags, and .describe() on every top-level input field. Removes "coupons." from DESCRIPTION_BACKLOG, completing batch 6. --- .../__tests__/public-spec-operations.test.ts | 1 - .../src/features/coupons/api/public.ts | 56 +++++++++++++-- .../src/features/coupons/schema/public.ts | 69 +++++++++++++++---- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 1f432c9642..1e3e3c25f2 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -64,7 +64,6 @@ const DESCRIPTION_BACKLOG = new Set([ "botFields.", "channels.", "contactScans.", - "coupons.", "customFields.", "emailTopics.", "fbComments.", diff --git a/apps/builder/src/features/coupons/api/public.ts b/apps/builder/src/features/coupons/api/public.ts index b090b8f36b..a4ceb52d40 100644 --- a/apps/builder/src/features/coupons/api/public.ts +++ b/apps/builder/src/features/coupons/api/public.ts @@ -37,6 +37,8 @@ export const couponsPublicRouter = { method: "GET", path: "/v1/coupon-topics", summary: "List coupon topics", + description: + "Use this to find coupon topic ids before inspecting one with `coupons.getTopic` or issuing from it with `coupons.issueCoupon`. Returns coupon topics in this workspace.", tags, }) .input(withPublicPaging(listCouponTopicsPublicRequest.omit({ sort: true }))) @@ -55,9 +57,17 @@ export const couponsPublicRouter = { method: "GET", path: "/v1/coupon-topics/{id}", summary: "Get a coupon topic", + description: + "Returns one coupon topic's settings. Use `coupons.listTopics` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + }), + ) .output(publicCouponTopicResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -94,6 +104,8 @@ export const couponsPublicRouter = { method: "PATCH", path: "/v1/coupon-topics/{id}", summary: "Update a coupon topic", + description: + "Changes an existing coupon topic's settings. Call `coupons.getTopic` to inspect current values first.", tags, }) .input(updateCouponTopicPublicRequest) @@ -113,9 +125,17 @@ export const couponsPublicRouter = { method: "POST", path: "/v1/coupon-topics/{id}/archive", summary: "Archive a coupon topic", + description: + "Stops a topic from being issueable via `coupons.issueCoupon` without deleting it. Use `coupons.unarchiveTopic` to reverse.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + }), + ) .output(publicCouponTopicResource) .errors(possibleErrorsOnMutatingResource) .handler( @@ -131,9 +151,17 @@ export const couponsPublicRouter = { method: "POST", path: "/v1/coupon-topics/{id}/unarchive", summary: "Unarchive a coupon topic", + description: + "Reactivates an archived coupon topic so it becomes issueable via `coupons.issueCoupon` again.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + }), + ) .output(publicCouponTopicResource) .errors(possibleErrorsOnMutatingResource) .handler( @@ -149,9 +177,17 @@ export const couponsPublicRouter = { method: "DELETE", path: "/v1/coupon-topics/{id}", summary: "Delete a coupon topic", + description: + "Permanently deletes a coupon topic. Use `coupons.listTopics` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + }), + ) .output(publicCouponTopicResource) .errors(possibleErrorsOnDeletingResource) .handler( @@ -167,6 +203,8 @@ export const couponsPublicRouter = { method: "GET", path: "/v1/coupons", summary: "List coupons", + description: + "Use this to find individual coupon codes across topics. Returns coupons in this workspace.", tags, }) .input(withPublicPaging(listCouponsPublicRequest.omit({ sort: true }))) @@ -268,9 +306,17 @@ export const couponsPublicRouter = { method: "GET", path: "/v1/contacts/{contactId}/coupons", summary: "List coupons issued to a contact", + description: + "Returns every coupon issued to a specific contact, across all topics. Use `contacts.list` to find the contact id first.", tags, }) - .input(z.object({ contactId: zodBigintAsString() })) + .input( + z.object({ + contactId: zodBigintAsString().describe( + "Contact id. Get it from `contacts.list`.", + ), + }), + ) .output(listContactCouponsPublicResponse) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { diff --git a/apps/builder/src/features/coupons/schema/public.ts b/apps/builder/src/features/coupons/schema/public.ts index 2c3fcda757..fbe8ceab86 100644 --- a/apps/builder/src/features/coupons/schema/public.ts +++ b/apps/builder/src/features/coupons/schema/public.ts @@ -28,8 +28,16 @@ export const publicCouponTopicResource = couponTopicResource.pick({ }) export const listCouponTopicsPublicRequest = basePaginationRequest.extend({ - archived: z.boolean().optional(), - search: z.string().optional(), + archived: z + .boolean() + .optional() + .describe( + "Restrict to archived topics when true, active topics when false.", + ), + search: z + .string() + .optional() + .describe("Case-insensitive substring match against the topic's name."), }) // `list` is the only topic route that joins the coupon count; the six @@ -42,21 +50,46 @@ export const listCouponTopicsPublicResponse = z.object({ }) export const createCouponTopicPublicRequest = z.object({ - name: z.string().trim().min(1).max(255), - description: z.string().trim().max(1000).optional().nullable(), - expiresAt: z.coerce.date().optional().nullable(), + name: z.string().trim().min(1).max(255).describe("Coupon topic name."), + description: z + .string() + .trim() + .max(1000) + .optional() + .nullable() + .describe("Optional internal description of the topic."), + expiresAt: z.coerce + .date() + .optional() + .nullable() + .describe( + "When coupons from this topic stop being issueable/usable, or null for never.", + ), }) export const updateCouponTopicPublicRequest = createCouponTopicPublicRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), }) export const listCouponsPublicRequest = basePaginationRequest.extend({ - topicId: zodBigintAsString().optional(), - issueStatus: couponIssueStatuses.optional(), - usageStatus: couponUsageStatuses.optional(), - search: z.string().optional(), + topicId: zodBigintAsString() + .optional() + .describe( + "Restrict to coupons from this topic. Get it from `coupons.listTopics`.", + ), + issueStatus: couponIssueStatuses + .optional() + .describe("Restrict to coupons with this issue status."), + usageStatus: couponUsageStatuses + .optional() + .describe("Restrict to coupons with this usage status."), + search: z + .string() + .optional() + .describe("Case-insensitive substring match against the coupon code."), }) export const listCouponsPublicResponse = z.object({ @@ -65,13 +98,21 @@ export const listCouponsPublicResponse = z.object({ }) export const issueCouponPublicRequest = z.object({ - id: zodBigintAsString(), - contactId: zodBigintAsString(), + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + contactId: zodBigintAsString().describe( + "Contact id. Get it from `contacts.list`.", + ), }) export const markCouponUsedPublicRequest = z.object({ - id: zodBigintAsString(), - contactId: zodBigintAsString(), + id: zodBigintAsString().describe( + "Coupon topic id. Get it from `coupons.listTopics`.", + ), + contactId: zodBigintAsString().describe( + "Contact id. Get it from `contacts.list`.", + ), }) export const listContactCouponsPublicResponse = z.object({ From 019e21cd9ec4c7c9855149bf596497f878fb7804 Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 14:08:39 +0700 Subject: [PATCH 35/38] feat(api): complete batch 7 public API description coverage WS1 batch 7: all appointmentCalendars/appointments/appointmentExternalCalendars/appointmentReminders/questionnaires/spreadsheets/emailTopics/botFields operations gain description, tags, and .describe() on every top-level input field. Removes their prefixes from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 8 - .../appointment-calendars/api/public.ts | 10 ++ .../appointment-calendars/schema/action.ts | 159 ++++++++++++++---- .../appointment-calendars/schema/public.ts | 31 +++- .../appointment-management/schema/public.ts | 4 +- .../src/features/appointments/api/public.ts | 4 + .../features/appointments/schema/public.ts | 40 ++++- .../src/features/bot-fields/api/public.ts | 90 ++++++++-- .../src/features/bot-fields/schema/action.ts | 23 ++- .../src/features/email-topics/api/public.ts | 27 ++- .../features/email-topics/schema/action.ts | 8 +- .../features/email-topics/schema/public.ts | 4 +- .../external-calendars/schema/public.ts | 4 +- .../src/features/questionnaires/api/public.ts | 30 +++- .../features/questionnaires/schema/action.ts | 99 ++++++++--- .../features/questionnaires/schema/public.ts | 60 +++++-- .../src/features/spreadsheets/api/public.ts | 14 ++ .../features/spreadsheets/schema/mutation.ts | 5 +- .../features/spreadsheets/schema/public.ts | 27 ++- .../src/features/spreadsheets/schema/query.ts | 7 +- 20 files changed, 524 insertions(+), 130 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 1e3e3c25f2..eda7b28dd3 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -57,15 +57,9 @@ const LEGACY_WORKSPACE_TOKEN_PATTERN = /workspace[_.]?token/i const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ - "appointmentCalendars.", - "appointmentExternalCalendars.", - "appointmentReminders.", - "appointments.", - "botFields.", "channels.", "contactScans.", "customFields.", - "emailTopics.", "fbComments.", "folders.", "igComments.", @@ -75,10 +69,8 @@ const DESCRIPTION_BACKLOG = new Set([ "messengerPersonas.", "productCategories.", "products.", - "questionnaires.", "reflinks.", "smtpIntegrations.", - "spreadsheets.", "tags.", "templateMessages.", "userPersistentMenus.", diff --git a/apps/builder/src/features/appointment-calendars/api/public.ts b/apps/builder/src/features/appointment-calendars/api/public.ts index 720202bf28..51c9f693e2 100644 --- a/apps/builder/src/features/appointment-calendars/api/public.ts +++ b/apps/builder/src/features/appointment-calendars/api/public.ts @@ -37,6 +37,8 @@ export const appointmentCalendarsPublicRouter = { method: "GET", path: "/v1/appointment-calendars", summary: "List appointment calendars", + description: + "Use this to find calendar ids before inspecting one with `appointmentCalendars.get` or checking slots with `appointmentCalendars.getAvailability`. Returns calendars in this workspace.", tags, }) .input(listAppointmentCalendarsPublicRequest) @@ -96,6 +98,8 @@ export const appointmentCalendarsPublicRouter = { method: "PUT", path: "/v1/appointment-calendars/{id}", summary: "Update an appointment calendar", + description: + "Replaces an existing calendar's full configuration (duration, availability, buffers, reminders). Call `appointmentCalendars.get` to inspect current values first.", tags, }) .input( @@ -117,6 +121,8 @@ export const appointmentCalendarsPublicRouter = { method: "PATCH", path: "/v1/appointment-calendars/{id}/active", summary: "Activate or deactivate an appointment calendar", + description: + "Toggles whether a calendar accepts new bookings via `appointments.book`, without changing its configuration.", tags, }) .input( @@ -138,6 +144,8 @@ export const appointmentCalendarsPublicRouter = { method: "POST", path: "/v1/appointment-calendars/{id}/duplicate", summary: "Duplicate an appointment calendar", + description: + "Copies an existing calendar's configuration into a new calendar. Use `appointmentCalendars.update` afterward to adjust the copy.", successStatus: 201, tags, }) @@ -157,6 +165,8 @@ export const appointmentCalendarsPublicRouter = { method: "DELETE", path: "/v1/appointment-calendars/{id}", summary: "Delete an appointment calendar", + description: + "Permanently deletes a calendar. Use `appointmentCalendars.list` to find its id first.", successStatus: 204, tags, }) diff --git a/apps/builder/src/features/appointment-calendars/schema/action.ts b/apps/builder/src/features/appointment-calendars/schema/action.ts index eede2369a1..777f03cc35 100644 --- a/apps/builder/src/features/appointment-calendars/schema/action.ts +++ b/apps/builder/src/features/appointment-calendars/schema/action.ts @@ -11,7 +11,7 @@ import { z } from "zod" export const appointmentCalendarNameSchema = z.string().trim().min(1).max(255) export const createAppointmentCalendarRequest = z.object({ - name: appointmentCalendarNameSchema, + name: appointmentCalendarNameSchema.describe("Calendar name."), }) export type CreateAppointmentCalendarRequest = z.infer< typeof createAppointmentCalendarRequest @@ -47,8 +47,19 @@ const optionalExternalEventTemplate = (maxLength: number) => ) const appointmentAvailabilityIntervalRequest = z.object({ - weekday: z.number().int().min(0).max(6), - startMinute: z.number().int().min(0).max(1425).multipleOf(15), + weekday: z + .number() + .int() + .min(0) + .max(6) + .describe("Day of week, 0 (Sunday) through 6 (Saturday)."), + startMinute: z + .number() + .int() + .min(0) + .max(1425) + .multipleOf(15) + .describe("Interval start, in minutes from midnight, on a 15-minute step."), endMinute: z .number() .int() @@ -56,27 +67,51 @@ const appointmentAvailabilityIntervalRequest = z.object({ .max(1439) .refine((value) => value === 1439 || value % 15 === 0, { message: "End time must be a 15-minute step or 23:59", - }), + }) + .describe( + "Interval end, in minutes from midnight, on a 15-minute step (or 23:59).", + ), }) export const appointmentReminderRequest = z.object({ - flowId: zodBigintAsString(), - timingValue: z.coerce.number().int().min(1), - timingUnit: appointmentReminderTimingUnits, + flowId: zodBigintAsString().describe( + "Flow to trigger for this reminder. Get it from `flows.list`.", + ), + timingValue: z.coerce + .number() + .int() + .min(1) + .describe( + "Number of timing units before the appointment to send the reminder.", + ), + timingUnit: appointmentReminderTimingUnits.describe( + "Unit for timingValue, e.g. minutes/hours/days.", + ), }) export const updateAppointmentCalendarRequest = z .object({ - name: appointmentCalendarNameSchema, - description: z.string().trim().max(2000).optional().nullable(), - active: z.boolean(), - timezone: z.string().trim().min(1), + name: appointmentCalendarNameSchema.describe("Calendar name."), + description: z + .string() + .trim() + .max(2000) + .optional() + .nullable() + .describe("Optional internal description of the calendar."), + active: z.boolean().describe("Whether the calendar accepts new bookings."), + timezone: z + .string() + .trim() + .min(1) + .describe("IANA timezone used to interpret availability and reminders."), durationMinutes: z.coerce .number() .int() .refine((value) => DURATION_MINUTE_VALUES.includes(value), { message: "Invalid duration", - }), + }) + .describe("Length of each appointment slot, in minutes."), bufferAfterMinutes: z.preprocess( (value) => value === noAppointmentCalendarSelectionValue || value === "" @@ -88,34 +123,98 @@ export const updateAppointmentCalendarRequest = z .refine((value) => BUFFER_MINUTE_VALUES.includes(value), { message: "Invalid buffer", }) - .nullable(), + .nullable() + .describe( + "Buffer time added after each appointment, in minutes, or null for none.", + ), + ), + locationType: appointmentLocationTypes.describe( + "Where the appointment takes place, e.g. in-person or video call.", + ), + locationDetail: z + .string() + .trim() + .max(500) + .optional() + .nullable() + .describe("Address or link shown to the invitee for this location type."), + scheduleWindowConfig: appointmentScheduleWindowConfigSchema.describe( + "How far ahead bookings are allowed (e.g. a rolling window or fixed date range).", ), - locationType: appointmentLocationTypes, - locationDetail: z.string().trim().max(500).optional().nullable(), - scheduleWindowConfig: appointmentScheduleWindowConfigSchema, maxAppointmentsPerUser: z.preprocess( (value) => (value === "" || value == null ? null : value), - z.coerce.number().int().min(1).nullable(), + z.coerce + .number() + .int() + .min(1) + .nullable() + .describe( + "Maximum number of active appointments a single contact may hold, or null for unlimited.", + ), ), - dailyLimitEnabled: z.boolean(), + dailyLimitEnabled: z.boolean().describe("Whether maxPerDay is enforced."), maxPerDay: z.preprocess( (value) => (value === "" || value == null ? null : value), - z.coerce.number().int().min(1).nullable(), + z.coerce + .number() + .int() + .min(1) + .nullable() + .describe( + "Maximum bookings per day when dailyLimitEnabled is true, or null when not set.", + ), ), - allowGroupMeeting: z.boolean(), + allowGroupMeeting: z + .boolean() + .describe("Whether multiple contacts can book the same slot."), maxPerSlot: z.preprocess( (value) => (value === "" || value == null ? null : value), - z.coerce.number().int().min(1).nullable(), + z.coerce + .number() + .int() + .min(1) + .nullable() + .describe( + "Maximum bookings per slot when allowGroupMeeting is true, or null for unlimited.", + ), + ), + confirmationMessage: z + .string() + .trim() + .max(2000) + .optional() + .nullable() + .describe("Message shown to the invitee after booking."), + confirmationFlowId: optionalFlowIdField.describe( + "Flow to trigger when an appointment is booked, or null for none.", + ), + cancellationFlowId: optionalFlowIdField.describe( + "Flow to trigger when an appointment is cancelled, or null for none.", + ), + externalConnectionId: optionalFlowIdField.describe( + "External calendar connection (from `appointmentExternalCalendars.list`) to sync bookings to, or null for none.", + ), + externalEventTitleTemplate: optionalExternalEventTemplate(1024).describe( + "Template for the external calendar event's title, or null to use the default.", + ), + externalEventDescriptionTemplate: optionalExternalEventTemplate( + 8192, + ).describe( + "Template for the external calendar event's description, or null to use the default.", + ), + externalEventAttendeesTemplate: optionalExternalEventTemplate( + 8192, + ).describe( + "Template for the external calendar event's attendee list, or null to use the default.", ), - confirmationMessage: z.string().trim().max(2000).optional().nullable(), - confirmationFlowId: optionalFlowIdField, - cancellationFlowId: optionalFlowIdField, - externalConnectionId: optionalFlowIdField, - externalEventTitleTemplate: optionalExternalEventTemplate(1024), - externalEventDescriptionTemplate: optionalExternalEventTemplate(8192), - externalEventAttendeesTemplate: optionalExternalEventTemplate(8192), - availability: z.array(appointmentAvailabilityIntervalRequest).max(70), - reminders: z.array(appointmentReminderRequest).max(50), + availability: z + .array(appointmentAvailabilityIntervalRequest) + .max(70) + .describe("Weekly recurring availability intervals."), + reminders: z + .array(appointmentReminderRequest) + .max(50) + .describe("Reminder flows to trigger before each appointment."), }) .superRefine((data, ctx) => { if (data.dailyLimitEnabled && data.maxPerDay == null) { diff --git a/apps/builder/src/features/appointment-calendars/schema/public.ts b/apps/builder/src/features/appointment-calendars/schema/public.ts index abcfa70365..4a82ab668b 100644 --- a/apps/builder/src/features/appointment-calendars/schema/public.ts +++ b/apps/builder/src/features/appointment-calendars/schema/public.ts @@ -47,7 +47,10 @@ export type AppointmentCalendarPublicResource = z.infer< > export const listAppointmentCalendarsPublicRequest = publicListRequest.extend({ - search: z.string().optional(), + search: z + .string() + .optional() + .describe("Case-insensitive substring match against the calendar's name."), }) const appointmentCalendarAvailabilityPublicResource = createSelectSchema( @@ -95,18 +98,32 @@ export const createAppointmentCalendarPublicResponse = z.object({ }) export const appointmentCalendarIdPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Appointment calendar id. Get it from `appointmentCalendars.list`.", + ), }) export const setAppointmentCalendarActivePublicRequest = z.object({ - active: z.boolean(), + active: z + .boolean() + .describe("Whether the calendar should accept new bookings."), }) export const getAppointmentCalendarAvailabilityPublicRequest = z.object({ - id: zodBigintAsString(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - contactId: zodBigintAsString().optional(), + id: zodBigintAsString().describe( + "Appointment calendar id. Get it from `appointmentCalendars.list`.", + ), + startDate: z.coerce + .date() + .describe("Start of the range to check for available slots."), + endDate: z.coerce + .date() + .describe("End of the range to check for available slots."), + contactId: zodBigintAsString() + .optional() + .describe( + "Contact id to exclude their own existing bookings from the daily/per-user limits.", + ), }) export const appointmentCalendarAvailabilityPublicResponse = z.object({ diff --git a/apps/builder/src/features/appointment-management/schema/public.ts b/apps/builder/src/features/appointment-management/schema/public.ts index bdf575af2a..92af066995 100644 --- a/apps/builder/src/features/appointment-management/schema/public.ts +++ b/apps/builder/src/features/appointment-management/schema/public.ts @@ -41,5 +41,7 @@ export type AppointmentReminderDispatchPublicResource = z.infer< > export const listAppointmentRemindersPublicRequest = publicListRequest.extend({ - status: appointmentReminderDispatchStatuses.optional(), + status: appointmentReminderDispatchStatuses + .optional() + .describe("Restrict to reminders with this dispatch status."), }) diff --git a/apps/builder/src/features/appointments/api/public.ts b/apps/builder/src/features/appointments/api/public.ts index 6438410773..5537bd9e76 100644 --- a/apps/builder/src/features/appointments/api/public.ts +++ b/apps/builder/src/features/appointments/api/public.ts @@ -55,6 +55,8 @@ export const appointmentsPublicRouter = { method: "GET", path: "/v1/appointments/{id}", summary: "Get an appointment by id", + description: + "Returns one appointment's booking details. Use `appointments.list` to find its id first.", tags, }) .input(appointmentIdPublicRequest) @@ -102,6 +104,8 @@ export const appointmentsPublicRouter = { method: "POST", path: "/v1/appointments/{id}/cancel", summary: "Cancel an appointment", + description: + "Cancels a booked appointment without deleting its record. Use `appointments.list` to find its id first.", tags, }) .input(appointmentIdPublicRequest) diff --git a/apps/builder/src/features/appointments/schema/public.ts b/apps/builder/src/features/appointments/schema/public.ts index 247aea3776..ee10f9ad95 100644 --- a/apps/builder/src/features/appointments/schema/public.ts +++ b/apps/builder/src/features/appointments/schema/public.ts @@ -63,19 +63,41 @@ export type AppointmentListItemPublicResource = z.infer< export const appointmentListTabs = ["next", "past"] as const export const listAppointmentsPublicRequest = publicListRequest.extend({ - calendarId: zodBigintAsString().optional(), - tab: z.enum(appointmentListTabs).optional(), - search: z.string().optional(), + calendarId: zodBigintAsString() + .optional() + .describe( + "Restrict to appointments on this calendar. Get it from `appointmentCalendars.list`.", + ), + tab: z + .enum(appointmentListTabs) + .optional() + .describe("Restrict to upcoming (`next`) or past appointments."), + search: z + .string() + .optional() + .describe("Case-insensitive substring match against the contact's name."), }) export const appointmentIdPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Appointment id. Get it from `appointments.list`.", + ), }) export const bookAppointmentPublicRequest = z.object({ - calendarId: zodBigintAsString(), - contactId: zodBigintAsString(), - conversationId: zodBigintAsString().optional().nullable(), - startAt: z.coerce.date(), - inviteeTimezone: z.string().optional(), + calendarId: zodBigintAsString().describe( + "Calendar to book on. Get it from `appointmentCalendars.list`.", + ), + contactId: zodBigintAsString().describe( + "Contact id. Get it from `contacts.list`.", + ), + conversationId: zodBigintAsString() + .optional() + .nullable() + .describe("Conversation to associate the booking with, if any."), + startAt: z.coerce.date().describe("Slot start time."), + inviteeTimezone: z + .string() + .optional() + .describe("IANA timezone of the invitee, for display purposes."), }) diff --git a/apps/builder/src/features/bot-fields/api/public.ts b/apps/builder/src/features/bot-fields/api/public.ts index 551f866089..887ce5490c 100644 --- a/apps/builder/src/features/bot-fields/api/public.ts +++ b/apps/builder/src/features/bot-fields/api/public.ts @@ -21,6 +21,8 @@ export const botFieldsPublicRouter = { method: "GET", path: "/v1/bot-fields", summary: "Get all bot fields", + description: + "Use this to find bot field names before reading one with `botFields.get` or setting a value with `botFields.set`. Returns bot fields in this workspace.", tags: ["Bot Fields"], }) .input(publicListRequest) @@ -42,6 +44,8 @@ export const botFieldsPublicRouter = { method: "POST", path: "/v1/bot-fields", summary: "Create a new bot field", + description: + "Adds a custom bot field definition (a global variable available to every flow). Use `botFields.list` first to avoid duplicating an existing name.", successStatus: 201, tags: ["Bot Fields"], }) @@ -61,9 +65,18 @@ export const botFieldsPublicRouter = { method: "GET", path: "/v1/bot-fields/{idOrName}", summary: "Get bot field by id or name", + description: + "Returns one bot field's current value. Use `botFields.list` to find its id or name first.", tags: ["Bot Fields"], }) - .input(z.object({ idOrName: z.string().max(255) })) + .input( + z.object({ + idOrName: z + .string() + .max(255) + .describe("Bot field id or name. Get it from `botFields.list`."), + }), + ) .output(publicBotFieldResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -79,10 +92,18 @@ export const botFieldsPublicRouter = { method: "PUT", path: "/v1/bot-fields/{idOrName}", summary: "Set bot field value by id or name", + description: + "Changes an existing bot field's value. Call `botFields.get` to inspect the current value first.", tags: ["Bot Fields"], }) .input( - z.object({ idOrName: z.string().max(255), value: z.string().max(255) }), + z.object({ + idOrName: z + .string() + .max(255) + .describe("Bot field id or name. Get it from `botFields.list`."), + value: z.string().max(255).describe("New value for the bot field."), + }), ) .output(publicBotFieldResource) .errors(possibleErrorsOnMutatingResource) @@ -100,14 +121,24 @@ export const botFieldsPublicRouter = { method: "PUT", path: "/v1/bot-fields", summary: "Set multiple bot field values", + description: + "Changes several bot fields' values in one call, addressed by name. Use `botFields.list` to find valid field names first.", successStatus: 204, tags: ["Bot Fields"], }) .input( z.object({ - fields: z.array( - z.object({ key: z.string().max(255), value: z.string().max(255) }), - ), + fields: z + .array( + z.object({ + key: z.string().max(255).describe("Bot field name."), + value: z + .string() + .max(255) + .describe("New value for the bot field."), + }), + ) + .describe("Bot fields to update."), }), ) .errors(possibleErrorsOnMutatingResource) @@ -128,23 +159,37 @@ export const botFieldsPublicRouter = { method: "PUT", path: "/v1/bot-fields/bulk-update", summary: "Bulk update bot field values by id or name", + description: + "Changes several bot fields' values in one call, addressed by id or name. Unlike `botFields.setMany`, each entry may target either an id or a name.", successStatus: 204, tags: ["Bot Fields"], }) .input( z.object({ - fields: z.array( - z.union([ - z.object({ - id: z.coerce.number().int().positive(), - value: z.union([z.string(), z.number()]).transform(String), - }), - z.object({ - name: z.string().max(255), - value: z.union([z.string(), z.number()]).transform(String), - }), - ]), - ), + fields: z + .array( + z.union([ + z.object({ + id: z.coerce + .number() + .int() + .positive() + .describe("Bot field id. Get it from `botFields.list`."), + value: z + .union([z.string(), z.number()]) + .transform(String) + .describe("New value for the bot field."), + }), + z.object({ + name: z.string().max(255).describe("Bot field name."), + value: z + .union([z.string(), z.number()]) + .transform(String) + .describe("New value for the bot field."), + }), + ]), + ) + .describe("Bot fields to update, each addressed by id or name."), }), ) .errors(possibleErrorsOnMutatingResource) @@ -163,10 +208,19 @@ export const botFieldsPublicRouter = { method: "DELETE", path: "/v1/bot-fields/{idOrName}", summary: "Unset the value of the bot field by id or name", + description: + "Clears an existing bot field's value back to empty. Use `botFields.list` to find its id or name first.", successStatus: 204, tags: ["Bot Fields"], }) - .input(z.object({ idOrName: z.string().max(255) })) + .input( + z.object({ + idOrName: z + .string() + .max(255) + .describe("Bot field id or name. Get it from `botFields.list`."), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler( async ({ context, input }) => diff --git a/apps/builder/src/features/bot-fields/schema/action.ts b/apps/builder/src/features/bot-fields/schema/action.ts index 87f4881dbe..d0e61d50f7 100644 --- a/apps/builder/src/features/bot-fields/schema/action.ts +++ b/apps/builder/src/features/bot-fields/schema/action.ts @@ -4,11 +4,24 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createBotFieldRequest = z.object({ - name: zodFieldName(), - type: customFieldTypes, - value: z.string().trim().max(1000).nullable(), - description: z.string().max(1000).nullable(), - folderId: zodBigintAsString().nullish(), + name: zodFieldName().describe( + "Bot field name, used to reference it in flows.", + ), + type: customFieldTypes.describe("Bot field data type."), + value: z + .string() + .trim() + .max(1000) + .nullable() + .describe("Initial value, or null for none."), + description: z + .string() + .max(1000) + .nullable() + .describe("Optional internal description."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the field in, or null for root-level."), }) export type CreateBotFieldRequest = z.infer diff --git a/apps/builder/src/features/email-topics/api/public.ts b/apps/builder/src/features/email-topics/api/public.ts index d179712730..bff530d91e 100644 --- a/apps/builder/src/features/email-topics/api/public.ts +++ b/apps/builder/src/features/email-topics/api/public.ts @@ -27,6 +27,8 @@ export const emailTopicsPublicRouter = { method: "GET", path: "/v1/email-topics", summary: "List email topics", + description: + "Use this to find email topic ids before inspecting one with `emailTopics.get`. Returns email topics in this workspace.", tags: ["EmailTopics"], }) .input(listEmailTopicsPublicRequest) @@ -45,9 +47,17 @@ export const emailTopicsPublicRouter = { method: "GET", path: "/v1/email-topics/{id}", summary: "Get email topic", + description: + "Returns one email topic's settings. Use `emailTopics.list` to find its id first.", tags: ["EmailTopics"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Email topic id. Get it from `emailTopics.list`.", + ), + }), + ) .output(emailTopicPublicResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -63,6 +73,8 @@ export const emailTopicsPublicRouter = { method: "POST", path: "/v1/email-topics", summary: "Create an email topic", + description: + "Adds an email topic used to group broadcast unsubscribe preferences. Use `emailTopics.list` first to avoid duplicating an existing one.", successStatus: 201, tags: ["EmailTopics"], }) @@ -86,6 +98,8 @@ export const emailTopicsPublicRouter = { method: "PUT", path: "/v1/email-topics/{id}", summary: "Update email topic", + description: + "Changes an existing email topic's settings. Call `emailTopics.get` to inspect current values first.", tags: ["EmailTopics"], }) .input(updateEmailTopicPublicRequest) @@ -105,10 +119,17 @@ export const emailTopicsPublicRouter = { method: "DELETE", path: "/v1/email-topics/{id}", summary: "Delete email topic", - successStatus: 204, + description: + "Permanently deletes an email topic. Use `emailTopics.list` to find its id first.", tags: ["EmailTopics"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Email topic id. Get it from `emailTopics.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { // `delete` is a bulk method for the UI's multi-select, which treats a diff --git a/apps/builder/src/features/email-topics/schema/action.ts b/apps/builder/src/features/email-topics/schema/action.ts index e715c6b4cd..29570cddd6 100644 --- a/apps/builder/src/features/email-topics/schema/action.ts +++ b/apps/builder/src/features/email-topics/schema/action.ts @@ -2,12 +2,14 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import z from "zod" export const createEmailTopicRequest = z.object({ - name: z.string().trim().min(1).max(255), - folderId: zodBigintAsString().nullish(), + name: z.string().trim().min(1).max(255).describe("Email topic name."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the topic in, or null for root-level."), }) export type CreateEmailTopicRequest = z.infer export const updateEmailTopicRequest = z.object({ - name: z.string().trim().min(1).max(255), + name: z.string().trim().min(1).max(255).describe("New email topic name."), }) export type UpdateEmailTopicRequest = z.infer diff --git a/apps/builder/src/features/email-topics/schema/public.ts b/apps/builder/src/features/email-topics/schema/public.ts index 39679bcac1..17d3feafe9 100644 --- a/apps/builder/src/features/email-topics/schema/public.ts +++ b/apps/builder/src/features/email-topics/schema/public.ts @@ -23,5 +23,7 @@ export const listEmailTopicsPublicResponse = publicListResponse( export const createEmailTopicPublicRequest = createEmailTopicRequest export const updateEmailTopicPublicRequest = updateEmailTopicRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Email topic id. Get it from `emailTopics.list`.", + ), }) diff --git a/apps/builder/src/features/external-calendars/schema/public.ts b/apps/builder/src/features/external-calendars/schema/public.ts index 5cf82371d4..0eac1410cb 100644 --- a/apps/builder/src/features/external-calendars/schema/public.ts +++ b/apps/builder/src/features/external-calendars/schema/public.ts @@ -28,5 +28,7 @@ export const listAppointmentExternalCalendarsPublicResponse = publicListResponse(appointmentExternalCalendarPublicResource) export const appointmentExternalCalendarIdPublicRequest = z.object({ - integrationId: zodBigintAsString(), + integrationId: zodBigintAsString().describe( + "External calendar connection id. Get it from `appointmentExternalCalendars.list`.", + ), }) diff --git a/apps/builder/src/features/questionnaires/api/public.ts b/apps/builder/src/features/questionnaires/api/public.ts index d3d2610223..48d6263d1e 100644 --- a/apps/builder/src/features/questionnaires/api/public.ts +++ b/apps/builder/src/features/questionnaires/api/public.ts @@ -36,6 +36,8 @@ export const questionnairesPublicRouter = { method: "GET", path: "/v1/questionnaires", summary: "List questionnaires", + description: + "Use this to find questionnaire ids before inspecting one with `questionnaires.get` or listing submissions with `questionnaires.listSubmissions`. Returns questionnaires in this workspace.", tags: ["Questionnaires"], }) .input(listQuestionnairesPublicRequest) @@ -54,6 +56,8 @@ export const questionnairesPublicRouter = { method: "GET", path: "/v1/questionnaires/{id}", summary: "Get a questionnaire", + description: + "Returns one questionnaire's questions and settings. Use `questionnaires.list` to find its id first.", tags: ["Questionnaires"], }) .input(getQuestionnairePublicRequest) @@ -72,6 +76,8 @@ export const questionnairesPublicRouter = { method: "POST", path: "/v1/questionnaires", summary: "Create a questionnaire", + description: + "Adds an empty questionnaire with the given name. Use `questionnaires.update` afterward to add questions.", successStatus: 201, tags: ["Questionnaires"], }) @@ -90,6 +96,8 @@ export const questionnairesPublicRouter = { method: "PUT", path: "/v1/questionnaires/{id}", summary: "Update a questionnaire", + description: + "Replaces an existing questionnaire's questions and settings. Call `questionnaires.get` to inspect current values first.", tags: ["Questionnaires"], }) .input(updateQuestionnairePublicRequest) @@ -108,10 +116,18 @@ export const questionnairesPublicRouter = { method: "DELETE", path: "/v1/questionnaires/{id}", summary: "Delete a questionnaire", + description: + "Permanently deletes a questionnaire and its submissions. Use `questionnaires.list` to find its id first.", successStatus: 204, tags: ["Questionnaires"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await questionnaireService.deleteMany({ @@ -124,6 +140,8 @@ export const questionnairesPublicRouter = { method: "PATCH", path: "/v1/questionnaires/{id}/rename", summary: "Rename a questionnaire", + description: + "Changes a questionnaire's display name without touching its questions.", tags: ["Questionnaires"], }) .input(renameQuestionnairePublicRequest) @@ -141,6 +159,8 @@ export const questionnairesPublicRouter = { method: "POST", path: "/v1/questionnaires/{id}/duplicate", summary: "Duplicate a questionnaire", + description: + "Copies an existing questionnaire's questions and settings into a new questionnaire.", successStatus: 201, tags: ["Questionnaires"], }) @@ -159,6 +179,8 @@ export const questionnairesPublicRouter = { method: "GET", path: "/v1/questionnaires/{id}/submissions", summary: "List questionnaire submissions", + description: + "Returns submitted answers for a questionnaire. Use `questionnaires.getSubmission` to inspect one in full.", tags: ["Questionnaire submissions"], }) .input(listQuestionnaireSubmissionsPublicRequest) @@ -178,6 +200,8 @@ export const questionnairesPublicRouter = { method: "GET", path: "/v1/questionnaires/{id}/submissions/{submissionId}", summary: "Get a questionnaire submission", + description: + "Returns one submission's full answers. Use `questionnaires.listSubmissions` to find its id first.", tags: ["Questionnaire submissions"], }) .input(getQuestionnaireSubmissionPublicRequest) @@ -197,6 +221,8 @@ export const questionnairesPublicRouter = { method: "DELETE", path: "/v1/questionnaires/{id}/submissions/{submissionId}", summary: "Delete a questionnaire submission", + description: + "Permanently deletes a single submission's answers. Use `questionnaires.listSubmissions` to find its id first.", successStatus: 204, tags: ["Questionnaire submissions"], }) @@ -215,6 +241,8 @@ export const questionnairesPublicRouter = { method: "GET", path: "/v1/questionnaires/{id}/submissions/stats", summary: "Get questionnaire submission stats", + description: + "Returns aggregate submission counts and completion stats for a questionnaire.", tags: ["Questionnaire submissions"], }) .input(getQuestionnairePublicRequest) diff --git a/apps/builder/src/features/questionnaires/schema/action.ts b/apps/builder/src/features/questionnaires/schema/action.ts index 1e9c2171d1..2d3c87240a 100644 --- a/apps/builder/src/features/questionnaires/schema/action.ts +++ b/apps/builder/src/features/questionnaires/schema/action.ts @@ -8,7 +8,7 @@ import { z } from "zod" export const questionnaireNameSchema = z.string().trim().min(1).max(255) export const createQuestionnaireRequest = z.object({ - name: questionnaireNameSchema, + name: questionnaireNameSchema.describe("Questionnaire name."), }) export type CreateQuestionnaireRequest = z.infer< typeof createQuestionnaireRequest @@ -22,38 +22,93 @@ export type RenameQuestionnaireRequest = z.infer< export const noQuestionnaireTriggerFlowValue = "__none__" export const questionnaireOptionRequest = z.object({ - id: z.string().trim().min(1), - label: z.string().trim().min(1).max(255), - points: z.coerce.number().int().min(0).default(0), + id: z.string().trim().min(1).describe("Option id, stable across edits."), + label: z + .string() + .trim() + .min(1) + .max(255) + .describe("Option label shown to the respondent."), + points: z.coerce + .number() + .int() + .min(0) + .default(0) + .describe("Points awarded when this option is chosen."), }) export const questionnaireQuestionRequest = z.object({ - id: zodBigintAsString().optional(), - title: z.string().trim().min(1).max(1000), - type: supportedQuestionnaireQuestionTypes, - active: z.boolean(), - image: questionnaireQuestionImageSchema.optional().nullable(), - point: z.coerce.number().int().min(0).default(1), - retryMessage: z.string().trim().max(1000).optional().nullable(), - customFieldId: z.string().trim().optional().nullable(), - systemFieldKey: z.string().trim().optional().nullable(), + id: zodBigintAsString() + .optional() + .describe("Existing question id to update, or omit to add a new question."), + title: z + .string() + .trim() + .min(1) + .max(1000) + .describe("Question text shown to the respondent."), + type: supportedQuestionnaireQuestionTypes.describe( + "Question type, e.g. single/multiple choice or free text.", + ), + active: z.boolean().describe("Whether the question is shown to respondents."), + image: questionnaireQuestionImageSchema + .optional() + .nullable() + .describe("Optional image shown with the question."), + point: z.coerce + .number() + .int() + .min(0) + .default(1) + .describe("Points awarded for a correct/matching answer."), + retryMessage: z + .string() + .trim() + .max(1000) + .optional() + .nullable() + .describe( + "Message shown when a retry is required, or null for the default.", + ), + customFieldId: z + .string() + .trim() + .optional() + .nullable() + .describe("Custom field to store the answer in, or null for none."), + systemFieldKey: z + .string() + .trim() + .optional() + .nullable() + .describe("System contact field to store the answer in, or null for none."), config: z .object({ options: z.array(questionnaireOptionRequest).default([]), }) .optional() - .nullable(), + .nullable() + .describe("Choice options, required for choice-type questions."), }) export const updateQuestionnaireRequest = z.object({ - triggerFlowId: z.preprocess( - (value) => (value === noQuestionnaireTriggerFlowValue ? null : value), - zodBigintAsString().optional().nullable(), - ), - enableScore: z.boolean(), - enableRetryMessages: z.boolean(), - enableCustomFieldMapping: z.boolean(), - questions: z.array(questionnaireQuestionRequest).max(100), + triggerFlowId: z + .preprocess( + (value) => (value === noQuestionnaireTriggerFlowValue ? null : value), + zodBigintAsString().optional().nullable(), + ) + .describe("Flow to trigger on submission, or null for none."), + enableScore: z.boolean().describe("Whether respondent answers are scored."), + enableRetryMessages: z + .boolean() + .describe("Whether incorrect answers show a retry message."), + enableCustomFieldMapping: z + .boolean() + .describe("Whether answers are written to mapped custom fields."), + questions: z + .array(questionnaireQuestionRequest) + .max(100) + .describe("Ordered list of questions."), }) export type UpdateQuestionnaireRequest = z.infer< typeof updateQuestionnaireRequest diff --git a/apps/builder/src/features/questionnaires/schema/public.ts b/apps/builder/src/features/questionnaires/schema/public.ts index be9cab504c..b49932e222 100644 --- a/apps/builder/src/features/questionnaires/schema/public.ts +++ b/apps/builder/src/features/questionnaires/schema/public.ts @@ -8,6 +8,7 @@ import { z } from "zod" import { publicListRequest, publicListResponse } from "@/lib/public-api/list" import { createQuestionnaireRequest, + questionnaireNameSchema, questionnaireOptionRequest, renameQuestionnaireRequest, updateQuestionnaireRequest, @@ -42,7 +43,15 @@ const questionnaireListItemResource = z.object({ }) export const listQuestionnairesPublicRequest = publicListRequest.extend({ - name: z.string().trim().min(1).max(255).optional(), + name: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe( + "Case-insensitive substring match against the questionnaire's name.", + ), sort: z .array( z.object({ @@ -50,7 +59,8 @@ export const listQuestionnairesPublicRequest = publicListRequest.extend({ desc: z.boolean(), }), ) - .optional(), + .optional() + .describe('Sort order as [{ id: "name", desc }].'), }) export const listQuestionnairesPublicResponse = publicListResponse( @@ -58,7 +68,9 @@ export const listQuestionnairesPublicResponse = publicListResponse( ) export const getQuestionnairePublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), }) export const getQuestionnaireSubmissionStatsPublicResponse = z.object({ @@ -83,15 +95,24 @@ export const createQuestionnairePublicRequest = createQuestionnaireRequest export const updateQuestionnairePublicRequest = updateQuestionnaireRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), }) export const duplicateQuestionnairePublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), }) export const renameQuestionnairePublicRequest = - renameQuestionnaireRequest.extend({ id: zodBigintAsString() }) + renameQuestionnaireRequest.extend({ + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), + name: questionnaireNameSchema.describe("New questionnaire name."), + }) const questionnaireSubmissionSort = z.object({ id: z.enum(["name", "totalPoints", "status", "completedAt"]), @@ -100,9 +121,22 @@ const questionnaireSubmissionSort = z.object({ export const listQuestionnaireSubmissionsPublicRequest = publicListRequest.extend({ - id: zodBigintAsString(), - name: z.string().trim().min(1).max(255).optional(), - sort: z.array(questionnaireSubmissionSort).optional(), + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), + name: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe( + "Case-insensitive substring match against the respondent's name.", + ), + sort: z + .array(questionnaireSubmissionSort) + .optional() + .describe("Sort order."), }) const questionnaireSubmissionContactResource = z.object({ @@ -130,8 +164,12 @@ export const listQuestionnaireSubmissionsPublicResponse = z.object({ }) export const getQuestionnaireSubmissionPublicRequest = z.object({ - id: zodBigintAsString(), - submissionId: zodBigintAsString(), + id: zodBigintAsString().describe( + "Questionnaire id. Get it from `questionnaires.list`.", + ), + submissionId: zodBigintAsString().describe( + "Submission id. Get it from `questionnaires.listSubmissions`.", + ), }) export const questionnaireSubmissionPublicResource = z.object({ diff --git a/apps/builder/src/features/spreadsheets/api/public.ts b/apps/builder/src/features/spreadsheets/api/public.ts index 6eb0366652..49ee12730c 100644 --- a/apps/builder/src/features/spreadsheets/api/public.ts +++ b/apps/builder/src/features/spreadsheets/api/public.ts @@ -37,6 +37,8 @@ export const spreadsheetsPublicRouter = { method: "GET", path: "/v1/spreadsheets", summary: "List spreadsheets", + description: + "Use this to find spreadsheet ids before inspecting one with `spreadsheets.get` or listing its worksheets with `spreadsheets.listWorksheets`. Returns spreadsheets in this workspace.", tags: ["Spreadsheets"], }) .input(listSpreadsheetsPublicRequest) @@ -55,6 +57,8 @@ export const spreadsheetsPublicRouter = { method: "GET", path: "/v1/spreadsheets/{id}", summary: "Get a spreadsheet", + description: + "Returns one connected spreadsheet's settings. Use `spreadsheets.list` to find its id first.", tags: ["Spreadsheets"], }) .input(getSpreadsheetPublicRequest) @@ -73,6 +77,8 @@ export const spreadsheetsPublicRouter = { method: "POST", path: "/v1/spreadsheets", summary: "Create a spreadsheet", + description: + "Connects a Google Sheets spreadsheet by its shareable URL. Requires the workspace's Google Sheets integration to be set up first.", successStatus: 201, tags: ["Spreadsheets"], }) @@ -93,6 +99,8 @@ export const spreadsheetsPublicRouter = { method: "PUT", path: "/v1/spreadsheets/{id}", summary: "Update a spreadsheet", + description: + "Changes an existing spreadsheet connection's settings. Call `spreadsheets.get` to inspect current values first.", tags: ["Spreadsheets"], }) .input(updateSpreadsheetPublicRequest) @@ -113,6 +121,8 @@ export const spreadsheetsPublicRouter = { method: "DELETE", path: "/v1/spreadsheets/{id}", summary: "Delete a spreadsheet", + description: + "Disconnects a spreadsheet. Use `spreadsheets.list` to find its id first.", successStatus: 204, tags: ["Spreadsheets"], }) @@ -130,6 +140,8 @@ export const spreadsheetsPublicRouter = { method: "GET", path: "/v1/spreadsheets/{spreadsheetId}/worksheets", summary: "List worksheets", + description: + "Returns the sheet tabs (worksheets) inside a connected spreadsheet. Use `spreadsheets.list` to find the spreadsheet id first.", tags: ["Spreadsheets"], }) .input(listWorksheetsPublicRequest) @@ -148,6 +160,8 @@ export const spreadsheetsPublicRouter = { method: "GET", path: "/v1/spreadsheets/{spreadsheetId}/worksheets/{worksheetName}/headers", summary: "List worksheet headers", + description: + "Returns the column headers of a worksheet's first row. Use `spreadsheets.listWorksheets` to find the worksheet name first.", tags: ["Spreadsheets"], }) .input(listWorksheetHeadersPublicRequest) diff --git a/apps/builder/src/features/spreadsheets/schema/mutation.ts b/apps/builder/src/features/spreadsheets/schema/mutation.ts index 5154bfc11c..cf3f4e63cc 100644 --- a/apps/builder/src/features/spreadsheets/schema/mutation.ts +++ b/apps/builder/src/features/spreadsheets/schema/mutation.ts @@ -15,13 +15,14 @@ const isGoogleSpreadsheetUrl = (url: string): boolean => { } export const createSpreadsheetRequest = z.object({ - name: z.string().min(1).max(255), + name: z.string().min(1).max(255).describe("Spreadsheet display name."), url: z .url() .refine( isGoogleSpreadsheetUrl, "URL must be a valid Google Spreadsheet link", - ), + ) + .describe("Shareable Google Sheets URL."), }) export type CreateSpreadsheetRequest = z.infer diff --git a/apps/builder/src/features/spreadsheets/schema/public.ts b/apps/builder/src/features/spreadsheets/schema/public.ts index 411f1a2c84..b701cee9fd 100644 --- a/apps/builder/src/features/spreadsheets/schema/public.ts +++ b/apps/builder/src/features/spreadsheets/schema/public.ts @@ -18,29 +18,42 @@ export const listSpreadsheetsPublicResponse = publicListResponse( ) export const getSpreadsheetPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Spreadsheet id. Get it from `spreadsheets.list`.", + ), }) export const createSpreadsheetPublicRequest = createSpreadsheetRequest export const createSpreadsheetPublicResponse = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe("New spreadsheet id."), }) export const updateSpreadsheetPublicRequest = createSpreadsheetRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Spreadsheet id. Get it from `spreadsheets.list`.", + ), }) export const deleteSpreadsheetPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Spreadsheet id. Get it from `spreadsheets.list`.", + ), }) export const listWorksheetsPublicRequest = z.object({ - spreadsheetId: zodBigintAsString(), + spreadsheetId: zodBigintAsString().describe( + "Spreadsheet id. Get it from `spreadsheets.list`.", + ), }) export { listWorksheetsResponse } from "./query" export const listWorksheetHeadersPublicRequest = z.object({ - spreadsheetId: zodBigintAsString(), - worksheetName: z.string(), + spreadsheetId: zodBigintAsString().describe( + "Spreadsheet id. Get it from `spreadsheets.list`.", + ), + worksheetName: z + .string() + .describe("Worksheet name. Get it from `spreadsheets.listWorksheets`."), }) + export { listWorksheetHeadersResponse } from "./query" diff --git a/apps/builder/src/features/spreadsheets/schema/query.ts b/apps/builder/src/features/spreadsheets/schema/query.ts index 641ba04093..13c3c44f0e 100644 --- a/apps/builder/src/features/spreadsheets/schema/query.ts +++ b/apps/builder/src/features/spreadsheets/schema/query.ts @@ -10,7 +10,12 @@ export const listSpreadsheetsRequest = z.object({ workspaceId: zodBigintAsString(), page: z.number().optional(), perPage: z.number().optional(), - name: z.string().optional(), + name: z + .string() + .optional() + .describe( + "Case-insensitive substring match against the spreadsheet's name.", + ), }) export type ListSpreadsheetsRequest = z.infer From 6e132ac48444fac84b261a0b0b99a7df68d3d44d Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 14:14:48 +0700 Subject: [PATCH 36/38] feat(api): complete batch 8 public API description coverage WS1 batch 8: all contactScans/customFields/folders/productCategories/products/reflinks/tags/userPersistentMenus operations gain description, tags, and .describe() on every top-level input field. Removes their prefixes from DESCRIPTION_BACKLOG. --- .../__tests__/public-spec-operations.test.ts | 8 -- .../src/features/contact-scan/api/public.ts | 4 + .../features/contact-scan/schema/public.ts | 8 +- .../src/features/contact-scan/schema/query.ts | 4 +- .../src/features/custom-fields/api/public.ts | 34 ++++- .../features/custom-fields/schema/action.ts | 20 +-- .../src/features/folders/api/public.ts | 14 ++- .../src/features/folders/schema/public.ts | 28 +++-- .../features/product-categories/api/public.ts | 12 +- .../product-categories/schema/public.ts | 16 ++- .../src/features/products/api/public.ts | 30 ++++- .../src/features/products/schema/action.ts | 118 ++++++++++++++---- .../src/features/products/schema/query.ts | 13 +- .../src/features/reflinks/api/public.ts | 36 +++++- .../src/features/reflinks/schema/action.ts | 12 +- apps/builder/src/features/tags/api/public.ts | 28 ++++- .../src/features/tags/schema/action.ts | 6 +- .../user-persistent-menus/api/public.ts | 26 +++- .../user-persistent-menus/schema/action.ts | 7 +- .../user-persistent-menus/schema/public.ts | 8 +- 20 files changed, 350 insertions(+), 82 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index eda7b28dd3..3bab864090 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -58,22 +58,14 @@ const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i const DESCRIPTION_BACKLOG = new Set([ "channels.", - "contactScans.", - "customFields.", "fbComments.", - "folders.", "igComments.", "igStories.", "integrations.", "messengerChannels.", "messengerPersonas.", - "productCategories.", - "products.", - "reflinks.", "smtpIntegrations.", - "tags.", "templateMessages.", - "userPersistentMenus.", "webchats.", "zaloChannels.", ]) diff --git a/apps/builder/src/features/contact-scan/api/public.ts b/apps/builder/src/features/contact-scan/api/public.ts index dfe75cb83f..4e9c2751b2 100644 --- a/apps/builder/src/features/contact-scan/api/public.ts +++ b/apps/builder/src/features/contact-scan/api/public.ts @@ -23,6 +23,8 @@ export const contactScanPublicRouter = { method: "GET", path: "/v1/contact-scans/status", summary: "Get the latest Automatic Customer Scan status for an inbox", + description: + "Returns the most recent scan's progress and result for an inbox. Use `contactScans.create` to start a new scan, or `contactScans.list` for the full history.", tags: ["Contacts"], }) .input(getContactScanStatusPublicRequest) @@ -41,6 +43,8 @@ export const contactScanPublicRouter = { method: "GET", path: "/v1/contact-scans", summary: "List Automatic Customer Scan runs for the workspace", + description: + "Returns scan history across every inbox in the workspace. Use `contactScans.getStatus` to check one inbox's latest run.", tags: ["Contacts"], }) .input(listContactScansPublicRequest) diff --git a/apps/builder/src/features/contact-scan/schema/public.ts b/apps/builder/src/features/contact-scan/schema/public.ts index f4549728f5..d57f327b87 100644 --- a/apps/builder/src/features/contact-scan/schema/public.ts +++ b/apps/builder/src/features/contact-scan/schema/public.ts @@ -12,8 +12,12 @@ export const getContactScanStatusPublicRequest = export { getContactScanStatusResponse } from "./query" export const scheduleContactScanPublicRequest = z.object({ - inboxId: zodBigintAsString(), - scanFromAt: z.coerce.date(), + inboxId: zodBigintAsString().describe( + "Inbox id. Get it from `inboxes.list`.", + ), + scanFromAt: z.coerce + .date() + .describe("Only scan conversations started on or after this date."), }) export const scheduleContactScanPublicResponse = z.object({ diff --git a/apps/builder/src/features/contact-scan/schema/query.ts b/apps/builder/src/features/contact-scan/schema/query.ts index 8a01f9ef3c..d369bdf1ff 100644 --- a/apps/builder/src/features/contact-scan/schema/query.ts +++ b/apps/builder/src/features/contact-scan/schema/query.ts @@ -11,7 +11,9 @@ import { basePaginationRequest } from "@/lib/pagination" export const getContactScanStatusRequest = z.object({ workspaceId: zodBigintAsString(), - inboxId: zodBigintAsString(), + inboxId: zodBigintAsString().describe( + "Inbox id. Get it from `inboxes.list`.", + ), }) export type GetContactScanStatusRequest = z.infer< typeof getContactScanStatusRequest diff --git a/apps/builder/src/features/custom-fields/api/public.ts b/apps/builder/src/features/custom-fields/api/public.ts index 3f66825596..bcaad2fb4f 100644 --- a/apps/builder/src/features/custom-fields/api/public.ts +++ b/apps/builder/src/features/custom-fields/api/public.ts @@ -66,9 +66,19 @@ export const customFieldsPublicRouter = { method: "GET", path: "/v1/custom-fields/{idOrName}", summary: "Get custom field by id or name", + description: + "Returns one custom field's type and settings. Use `customFields.list` to find its id or name first.", tags: ["Custom Fields"], }) - .input(z.object({ idOrName: z.string() })) + .input( + z.object({ + idOrName: z + .string() + .describe( + "Custom field id or name. Get it from `customFields.list`.", + ), + }), + ) .output(publicCustomFieldResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => { @@ -87,9 +97,19 @@ export const customFieldsPublicRouter = { method: "PUT", path: "/v1/custom-fields/{id}", summary: "Update custom field", + description: + "Changes an existing custom field's settings. Use `customFields.list` to find its id first.", tags: ["Custom Fields"], }) - .input(updateCustomFieldRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateCustomFieldRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Custom field id. Get it from `customFields.list`.", + ), + }), + ), + ) .output(publicCustomFieldResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -105,10 +125,18 @@ export const customFieldsPublicRouter = { method: "DELETE", path: "/v1/custom-fields/{id}", summary: "Delete custom field", + description: + "Permanently deletes a custom field definition. Use `customFields.list` to find its id first.", successStatus: 204, tags: ["Custom Fields"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Custom field id. Get it from `customFields.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler( async ({ context, input }) => diff --git a/apps/builder/src/features/custom-fields/schema/action.ts b/apps/builder/src/features/custom-fields/schema/action.ts index 0ee871a758..292e3d5165 100644 --- a/apps/builder/src/features/custom-fields/schema/action.ts +++ b/apps/builder/src/features/custom-fields/schema/action.ts @@ -4,16 +4,22 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createCustomFieldRequest = z.object({ - name: zodFieldName(), - type: customFieldTypes, - folderId: zodBigintAsString().nullish(), - description: z.string().nullish(), + name: zodFieldName().describe( + "Custom field name, used to reference it in flows.", + ), + type: customFieldTypes.describe("Custom field data type."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the field in, or null for root-level."), + description: z.string().nullish().describe("Optional internal description."), }) export type CreateCustomFieldRequest = z.infer export const updateCustomFieldRequest = z.object({ - name: zodFieldName(), - description: z.string().optional(), - folderId: zodBigintAsString().nullish(), + name: zodFieldName().describe("New custom field name."), + description: z.string().optional().describe("Optional internal description."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the field in, or null for root-level."), }) export type UpdateCustomFieldRequest = z.infer diff --git a/apps/builder/src/features/folders/api/public.ts b/apps/builder/src/features/folders/api/public.ts index 13d1aa0a1a..060d373b7f 100644 --- a/apps/builder/src/features/folders/api/public.ts +++ b/apps/builder/src/features/folders/api/public.ts @@ -54,6 +54,8 @@ export const foldersPublicRouter = { method: "POST", path: "/v1/folders", summary: "Create a folder", + description: + "Adds a folder used to organize tags or custom fields. Use `folders.list` first to avoid duplicating an existing one.", tags: ["Folders"], }) .input(createFolderPublicRequest) @@ -79,6 +81,8 @@ export const foldersPublicRouter = { method: "PUT", path: "/v1/folders/{id}", summary: "Rename a folder", + description: + "Changes a folder's display name without moving its contents. Use `folders.list` to find its id first.", tags: ["Folders"], }) .input(updateFolderPublicRequest) @@ -101,10 +105,18 @@ export const foldersPublicRouter = { method: "DELETE", path: "/v1/folders/{id}", summary: "Delete a folder", + description: + "Permanently deletes a folder. Its contents are not deleted, only unfiled. Use `folders.list` to find its id first.", successStatus: 204, tags: ["Folders"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Folder id. Get it from `folders.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { const folder = await requireContactsFolder({ diff --git a/apps/builder/src/features/folders/schema/public.ts b/apps/builder/src/features/folders/schema/public.ts index ff174b6f4a..83cee803c6 100644 --- a/apps/builder/src/features/folders/schema/public.ts +++ b/apps/builder/src/features/folders/schema/public.ts @@ -12,27 +12,39 @@ export const contactsFolderTypes = z.enum(["tag", "customField"]) export type ContactsFolderType = z.infer export const listFoldersPublicRequest = z.object({ - folderType: contactsFolderTypes, - parentId: z.string().optional(), + folderType: contactsFolderTypes.describe( + "Folder category to list, `tag` or `customField`.", + ), + parentId: z + .string() + .optional() + .describe( + "Restrict to sub-folders of this parent. Omit for top-level folders.", + ), }) -export type ListFoldersPublicRequest = z.infer export const listFoldersPublicResponse = z.object({ data: z.array(folderResource), }) export const createFolderPublicRequest = z.object({ - name: createFolderSchema.shape.name, - folderType: contactsFolderTypes, - parentId: z.string().nullable().optional(), + name: createFolderSchema.shape.name.describe("Folder name."), + folderType: contactsFolderTypes.describe( + "Folder category, `tag` or `customField`.", + ), + parentId: z + .string() + .nullable() + .optional() + .describe("Parent folder id, or null/omit for a top-level folder."), }) export type CreateFolderPublicRequest = z.infer< typeof createFolderPublicRequest > export const updateFolderPublicRequest = z.object({ - id: zodBigintAsString(), - name: createFolderSchema.shape.name, + id: zodBigintAsString().describe("Folder id. Get it from `folders.list`."), + name: createFolderSchema.shape.name.describe("New folder name."), }) export type UpdateFolderPublicRequest = z.infer< typeof updateFolderPublicRequest diff --git a/apps/builder/src/features/product-categories/api/public.ts b/apps/builder/src/features/product-categories/api/public.ts index bb9fb893a5..8f8c03de85 100644 --- a/apps/builder/src/features/product-categories/api/public.ts +++ b/apps/builder/src/features/product-categories/api/public.ts @@ -61,6 +61,8 @@ export const productCategoriesPublicRouter = { method: "PATCH", path: "/v1/product-categories/{id}", summary: "Update a product category", + description: + "Changes an existing category's name or reparents it. Use `productCategories.list` to find its id first.", tags: ["Product Categories"], }) .input(updateProductCategoryPublicRequest) @@ -87,10 +89,18 @@ export const productCategoriesPublicRouter = { method: "DELETE", path: "/v1/product-categories/{id}", summary: "Delete a product category", + description: + "Permanently deletes a product category. Use `productCategories.list` to find its id first.", successStatus: 204, tags: ["Product Categories"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Product category id. Get it from `productCategories.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await productCategoryService.delete({ diff --git a/apps/builder/src/features/product-categories/schema/public.ts b/apps/builder/src/features/product-categories/schema/public.ts index d9b5069549..1520c3dc32 100644 --- a/apps/builder/src/features/product-categories/schema/public.ts +++ b/apps/builder/src/features/product-categories/schema/public.ts @@ -26,12 +26,20 @@ export const publicProductCategoryWriteResource = z.object({ }) export const createProductCategoryPublicRequest = z.object({ - name: z.string().trim().min(1).max(255), - parentId: zodBigintAsString().nullish(), - rank: z.number().int().optional(), + name: z.string().trim().min(1).max(255).describe("Category name."), + parentId: zodBigintAsString() + .nullish() + .describe("Parent category id, or omit/null for a top-level category."), + rank: z + .number() + .int() + .optional() + .describe("Sort order among sibling categories."), }) export const updateProductCategoryPublicRequest = createProductCategoryPublicRequest.extend({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Product category id. Get it from `productCategories.list`.", + ), }) diff --git a/apps/builder/src/features/products/api/public.ts b/apps/builder/src/features/products/api/public.ts index 735f6f11f7..5c59d45db2 100644 --- a/apps/builder/src/features/products/api/public.ts +++ b/apps/builder/src/features/products/api/public.ts @@ -27,6 +27,8 @@ export const productsPublicRouter = { method: "GET", path: "/v1/products", summary: "List products", + description: + "Use this to find product ids before inspecting one with `products.get` or changing one with `products.update`. Returns products in this workspace.", tags: ["Products"], }) .input(withPublicPaging(listProductsRequest.omit({ sort: true }))) @@ -49,7 +51,13 @@ export const productsPublicRouter = { "Returns full product detail, including variant options, variants, and addons.", tags: ["Products"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Product id. Get it from `products.list`.", + ), + }), + ) .output(publicProductDetailResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -62,6 +70,8 @@ export const productsPublicRouter = { method: "POST", path: "/v1/products", summary: "Create a product", + description: + "Adds a product, including its variant options, variants, and addons, in one call.", tags: ["Products"], }) .input(createProductPublicRequest) @@ -85,7 +95,13 @@ export const productsPublicRouter = { tags: ["Products"], }) .input( - updateProductPublicRequest.and(z.object({ id: zodBigintAsString() })), + updateProductPublicRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Product id. Get it from `products.list`.", + ), + }), + ), ) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -102,10 +118,18 @@ export const productsPublicRouter = { method: "DELETE", path: "/v1/products/{id}", summary: "Delete a product", + description: + "Permanently deletes a product and its variants/addons. Use `products.list` to find its id first.", successStatus: 204, tags: ["Products"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Product id. Get it from `products.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { // findById throws notFoundException (-> 404) for a missing id, so the diff --git a/apps/builder/src/features/products/schema/action.ts b/apps/builder/src/features/products/schema/action.ts index bc1e5dd56c..ffe943a6ae 100644 --- a/apps/builder/src/features/products/schema/action.ts +++ b/apps/builder/src/features/products/schema/action.ts @@ -4,25 +4,61 @@ import { DEFAULT_PRODUCT_CURRENCY } from "../constants" const CURRENCY_CODE_LENGTH = 3 export const productFormRequest = z.object({ - name: z.string().trim().min(1).max(255).default(""), - shortDescription: z.string().nullish().default(""), - longDescription: z.string().max(840).nullish().default(""), - price: z.coerce.number().min(0).default(0), - taxes: z.coerce.number().min(0).max(100).default(0), - discount: z.coerce.number().min(0).max(100).default(0), + name: z.string().trim().min(1).max(255).default("").describe("Product name."), + shortDescription: z + .string() + .nullish() + .default("") + .describe("Short summary shown in listings."), + longDescription: z + .string() + .max(840) + .nullish() + .default("") + .describe("Full product description."), + price: z.coerce + .number() + .min(0) + .default(0) + .describe("Base price, in the product's currency."), + taxes: z.coerce + .number() + .min(0) + .max(100) + .default(0) + .describe("Tax rate, as a percentage."), + discount: z.coerce + .number() + .min(0) + .max(100) + .default(0) + .describe("Discount rate, as a percentage."), currency: z .string() .trim() .length(CURRENCY_CODE_LENGTH) - .default(DEFAULT_PRODUCT_CURRENCY), + .default(DEFAULT_PRODUCT_CURRENCY) + .describe("ISO 4217 currency code."), productUrl: z .union([z.url(), z.literal("")]) .nullish() - .default(""), - sku: z.string().nullish().default(""), - inventoryPolicy: z.enum(["dont_track", "track"]).default("dont_track"), - inventoryQuantity: z.coerce.number().int().min(0).default(0), - allowOutOfStockPurchase: z.boolean().default(false), + .default("") + .describe("External product page URL, if any."), + sku: z.string().nullish().default("").describe("Stock keeping unit code."), + inventoryPolicy: z + .enum(["dont_track", "track"]) + .default("dont_track") + .describe("Whether inventoryQuantity is tracked and enforced."), + inventoryQuantity: z.coerce + .number() + .int() + .min(0) + .default(0) + .describe("Units in stock, used when inventoryPolicy is `track`."), + allowOutOfStockPurchase: z + .boolean() + .default(false) + .describe("Whether the product can still be purchased once out of stock."), images: z .array( z.object({ @@ -31,7 +67,8 @@ export const productFormRequest = z.object({ url: z.string().default(""), }), ) - .default([]), + .default([]) + .describe("Product images, each a link or an uploaded file."), variantOptions: z .array( z.object({ @@ -40,7 +77,8 @@ export const productFormRequest = z.object({ position: z.coerce.number().default(0), }), ) - .default([]), + .default([]) + .describe("Option axes (e.g. size, color) used to generate variants."), variants: z .array( z.object({ @@ -49,7 +87,10 @@ export const productFormRequest = z.object({ isEnabled: z.boolean().default(true), }), ) - .default([]), + .default([]) + .describe( + "Purchasable combinations of variantOptions, each with its own price.", + ), addons: z .array( z.object({ @@ -58,15 +99,44 @@ export const productFormRequest = z.object({ addonProductIds: z.array(z.string()).default([]), }), ) - .default([]), - tags: z.array(z.string()).default([]), - vendor: z.string().nullish(), - rank: z.coerce.number().int().default(10), - categoryId: z.string().regex(/^\d+$/).nullish(), - subcategoryId: z.string().regex(/^\d+$/).nullish(), - isSearchable: z.boolean().default(true), - allowSpecialRequest: z.boolean().default(false), - isAddonOnly: z.boolean().default(false), + .default([]) + .describe("Optional add-on groups offered alongside the product."), + tags: z + .array(z.string()) + .default([]) + .describe("Freeform labels for filtering/search."), + vendor: z.string().nullish().describe("Vendor or brand name."), + rank: z.coerce + .number() + .int() + .default(10) + .describe("Sort order among products."), + categoryId: z + .string() + .regex(/^\d+$/) + .nullish() + .describe("Category id. Get it from `productCategories.list`."), + subcategoryId: z + .string() + .regex(/^\d+$/) + .nullish() + .describe("Sub-category id, must be a child of categoryId."), + isSearchable: z + .boolean() + .default(true) + .describe("Whether the product appears in search/listing."), + allowSpecialRequest: z + .boolean() + .default(false) + .describe( + "Whether customers can attach a special request note when ordering.", + ), + isAddonOnly: z + .boolean() + .default(false) + .describe( + "Whether the product is only purchasable as an addon to another product.", + ), }) export type ProductFormRequest = z.infer diff --git a/apps/builder/src/features/products/schema/query.ts b/apps/builder/src/features/products/schema/query.ts index a740f3f081..4821996f93 100644 --- a/apps/builder/src/features/products/schema/query.ts +++ b/apps/builder/src/features/products/schema/query.ts @@ -25,8 +25,17 @@ export type ListProductsSearchParams = Awaited< } export const listProductsRequest = basePaginationRequest.extend({ - name: z.string().nullish(), - categoryId: z.string().regex(/^\d+$/).nullish(), + name: z + .string() + .nullish() + .describe("Case-insensitive substring match against the product's name."), + categoryId: z + .string() + .regex(/^\d+$/) + .nullish() + .describe( + "Restrict to products in this category. Get it from `productCategories.list`.", + ), }) export type ListProductsRequest = z.infer diff --git a/apps/builder/src/features/reflinks/api/public.ts b/apps/builder/src/features/reflinks/api/public.ts index 9ab9ef5e42..8cf3a09af3 100644 --- a/apps/builder/src/features/reflinks/api/public.ts +++ b/apps/builder/src/features/reflinks/api/public.ts @@ -21,6 +21,8 @@ export const reflinksPublicRouter = { method: "GET", path: "/v1/ref-links", summary: "List ref links", + description: + "Use this to find ref link ids before inspecting one with `reflinks.get` or changing one with `reflinks.update`. Returns ref links in this workspace.", tags: ["Ref Links"], }) .input(publicListRequest) @@ -39,9 +41,17 @@ export const reflinksPublicRouter = { method: "GET", path: "/v1/ref-links/{id}", summary: "Get a specific ref link", + description: + "Returns one ref link's target and settings. Use `reflinks.list` to find its id first.", tags: ["Ref Links"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Ref link id. Get it from `reflinks.list`.", + ), + }), + ) .output(reflinkResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -57,6 +67,8 @@ export const reflinksPublicRouter = { method: "POST", path: "/v1/ref-links", summary: "Create a ref link", + description: + "Adds a shareable link that redirects to a flow or destination. Use `reflinks.list` first to avoid duplicating an existing one.", successStatus: 201, tags: ["Ref Links"], }) @@ -76,9 +88,19 @@ export const reflinksPublicRouter = { method: "PUT", path: "/v1/ref-links/{id}", summary: "Update a ref link", + description: + "Changes an existing ref link's target or settings. Call `reflinks.get` to inspect current values first.", tags: ["Ref Links"], }) - .input(updateReflinkRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateReflinkRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Ref link id. Get it from `reflinks.list`.", + ), + }), + ), + ) .output(reflinkResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -94,10 +116,18 @@ export const reflinksPublicRouter = { method: "DELETE", path: "/v1/ref-links/{id}", summary: "Delete a ref link", + description: + "Permanently deletes a ref link. Use `reflinks.list` to find its id first.", successStatus: 204, tags: ["Ref Links"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Ref link id. Get it from `reflinks.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await reflinkService.deleteMany({ diff --git a/apps/builder/src/features/reflinks/schema/action.ts b/apps/builder/src/features/reflinks/schema/action.ts index 43078e1da5..abe7cec1cb 100644 --- a/apps/builder/src/features/reflinks/schema/action.ts +++ b/apps/builder/src/features/reflinks/schema/action.ts @@ -8,11 +8,17 @@ export const createReflinkRequest = z.object({ .string() .min(1) .max(50) - .refine((value) => REF_LINK_NAME_REGEX.test(value)), - flowId: zodBigintAsString(), + .refine((value) => REF_LINK_NAME_REGEX.test(value)) + .describe("Ref link name, alphanumeric only."), + flowId: zodBigintAsString().describe( + "Flow to trigger when the ref link is opened. Get it from `flows.list`.", + ), customFieldId: z .union([z.literal("").transform(() => null), zodBigintAsString()]) - .nullable(), + .nullable() + .describe( + "Custom field to stamp a click identifier into, or null for none.", + ), }) export type CreateReflinkRequest = z.infer diff --git a/apps/builder/src/features/tags/api/public.ts b/apps/builder/src/features/tags/api/public.ts index 95e1e4ae2f..8f36423fa3 100644 --- a/apps/builder/src/features/tags/api/public.ts +++ b/apps/builder/src/features/tags/api/public.ts @@ -65,9 +65,17 @@ export const tagsPublicRouter = { method: "GET", path: "/v1/tags/{idOrName}", summary: "Get tag by id or name", + description: + "Returns one tag's id and name. Use `tags.list` to find its id or name first.", tags: ["Tags"], }) - .input(z.object({ idOrName: z.string() })) + .input( + z.object({ + idOrName: z + .string() + .describe("Tag id or name. Get it from `tags.list`."), + }), + ) .output(tagResource.pick({ id: true, name: true })) .errors(possibleErrorsOnFindingResource) .handler( @@ -83,12 +91,16 @@ export const tagsPublicRouter = { method: "PUT", path: "/v1/tags/{id}", summary: "Update tag", + description: + "Renames an existing tag. Use `tags.list` to find its id first.", tags: ["Tags"], }) .input( - createTagRequest - .pick({ name: true }) - .and(z.object({ id: zodBigintAsString() })), + createTagRequest.pick({ name: true }).and( + z.object({ + id: zodBigintAsString().describe("Tag id. Get it from `tags.list`."), + }), + ), ) .output(publicTagResource) .errors(possibleErrorsOnMutatingResource) @@ -105,10 +117,16 @@ export const tagsPublicRouter = { method: "DELETE", path: "/v1/tags/{id}", summary: "Delete tag", + description: + "Removes a tag from the workspace. Use `tags.list` to find its id first.", successStatus: 204, tags: ["Tags"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe("Tag id. Get it from `tags.list`."), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { const { id } = input diff --git a/apps/builder/src/features/tags/schema/action.ts b/apps/builder/src/features/tags/schema/action.ts index 9719d502b3..6ab4fa01b9 100644 --- a/apps/builder/src/features/tags/schema/action.ts +++ b/apps/builder/src/features/tags/schema/action.ts @@ -3,8 +3,10 @@ import { z } from "zod" import { tagResource } from "./resource" export const createTagRequest = z.object({ - name: z.string().trim().min(1).max(255), - folderId: zodBigintAsString().nullish(), + name: z.string().trim().min(1).max(255).describe("Tag name."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the tag in, or null for root-level."), }) export type CreateTagRequest = z.input diff --git a/apps/builder/src/features/user-persistent-menus/api/public.ts b/apps/builder/src/features/user-persistent-menus/api/public.ts index eedd046c81..a075e43604 100644 --- a/apps/builder/src/features/user-persistent-menus/api/public.ts +++ b/apps/builder/src/features/user-persistent-menus/api/public.ts @@ -28,6 +28,8 @@ export const userPersistentMenusPublicRouter = { method: "GET", path: "/v1/user-persistent-menus", summary: "List user persistent menus", + description: + "Use this to find persistent menu ids before inspecting one with `userPersistentMenus.get` or changing one with `userPersistentMenus.update`. Returns persistent menus in this workspace.", tags: ["User Persistent Menus"], }) .input(publicListRequest) @@ -45,9 +47,17 @@ export const userPersistentMenusPublicRouter = { method: "GET", path: "/v1/user-persistent-menus/{id}", summary: "Get a user persistent menu by id", + description: + "Returns one persistent menu's items. Use `userPersistentMenus.list` to find its id first.", tags: ["User Persistent Menus"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "User persistent menu id. Get it from `userPersistentMenus.list`.", + ), + }), + ) .output(userPersistentMenuPublicResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -63,6 +73,8 @@ export const userPersistentMenusPublicRouter = { method: "POST", path: "/v1/user-persistent-menus", summary: "Create a user persistent menu", + description: + "Adds a persistent menu of quick-reply buttons shown to channel users. Use `userPersistentMenus.list` first to avoid duplicating an existing one.", successStatus: 201, tags: ["User Persistent Menus"], }) @@ -83,6 +95,8 @@ export const userPersistentMenusPublicRouter = { method: "PUT", path: "/v1/user-persistent-menus/{id}", summary: "Update a user persistent menu", + description: + "Replaces an existing persistent menu's items. Call `userPersistentMenus.get` to inspect current values first.", tags: ["User Persistent Menus"], }) .input(updateUserPersistentMenuPublicRequest) @@ -103,10 +117,18 @@ export const userPersistentMenusPublicRouter = { method: "DELETE", path: "/v1/user-persistent-menus/{id}", summary: "Delete a user persistent menu", + description: + "Permanently deletes a persistent menu. Use `userPersistentMenus.list` to find its id first.", successStatus: 204, tags: ["User Persistent Menus"], }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "User persistent menu id. Get it from `userPersistentMenus.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await userPersistentMenuService.delete({ diff --git a/apps/builder/src/features/user-persistent-menus/schema/action.ts b/apps/builder/src/features/user-persistent-menus/schema/action.ts index 9950b7f60f..74c793bab5 100644 --- a/apps/builder/src/features/user-persistent-menus/schema/action.ts +++ b/apps/builder/src/features/user-persistent-menus/schema/action.ts @@ -4,8 +4,11 @@ import { z } from "zod" import { userPersistentMenuResource } from "./resource" const userPersistentMenuRequest = z.object({ - name: z.string().trim().min(1).max(255), - persistentMenus: z.array(messengerPersistentMenuSchema).max(20), + name: z.string().trim().min(1).max(255).describe("Persistent menu name."), + persistentMenus: z + .array(messengerPersistentMenuSchema) + .max(20) + .describe("Menu items shown to channel users."), }) export const createUserPersistentMenuRequest = userPersistentMenuRequest diff --git a/apps/builder/src/features/user-persistent-menus/schema/public.ts b/apps/builder/src/features/user-persistent-menus/schema/public.ts index b7f378882d..5cdd8e4cc0 100644 --- a/apps/builder/src/features/user-persistent-menus/schema/public.ts +++ b/apps/builder/src/features/user-persistent-menus/schema/public.ts @@ -13,4 +13,10 @@ export const createUserPersistentMenuPublicRequest = createUserPersistentMenuRequest export const updateUserPersistentMenuPublicRequest = - updateUserPersistentMenuRequest.and(z.object({ id: zodBigintAsString() })) + updateUserPersistentMenuRequest.and( + z.object({ + id: zodBigintAsString().describe( + "User persistent menu id. Get it from `userPersistentMenus.list`.", + ), + }), + ) From bf69d022bfb55cff8bbbf72d94eae0e797e67cbb Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 14:23:56 +0700 Subject: [PATCH 37/38] feat(api): complete batch 9 public API description coverage WS1 batch 9 (final): all fbComments/igComments/igStories/channels/messengerChannels/smtpIntegrations/webchats/templateMessages/zaloChannels/integrations/messengerPersonas operations gain description, tags, and .describe() on every top-level input field. This empties DESCRIPTION_BACKLOG, so batch 10's ratchet-removal is folded in: deletes the DESCRIPTION_BACKLOG const and isDescriptionBacklogged helper, and un-conditions the description/tag/input-field coverage tests to run against every operation unconditionally. All 395 WS1 public API operations now have description, tags, and field-level .describe() coverage. --- .../__tests__/public-spec-operations.test.ts | 29 +------- .../src/features/fb-comments/api/public.ts | 12 +++ .../src/features/fb-comments/schema/action.ts | 62 ++++++++++++---- .../src/features/fb-comments/schema/public.ts | 30 ++++++-- .../src/features/ig-comments/api/public.ts | 12 +++ .../src/features/ig-comments/schema/action.ts | 61 ++++++++++++---- .../src/features/ig-comments/schema/public.ts | 34 +++++++-- .../src/features/ig-stories/api/public.ts | 12 +++ .../src/features/ig-stories/schema/action.ts | 41 ++++++++--- .../src/features/ig-stories/schema/public.ts | 32 ++++++-- .../features/integration-api/api/public.ts | 40 ++++++++-- .../integration-messenger/api/public.ts | 11 ++- .../features/integration-smtp/api/public.ts | 36 ++++++++- .../integration-smtp/schema/mutation.ts | 52 ++++++++++--- .../integration-webchat/api/public.ts | 34 ++++++++- .../integration-webchat/schema/mutation.ts | 59 +++++++++++---- .../integration-webchat/schema/public.ts | 27 +++++-- .../message-templates/api/public.ts | 2 + .../message-templates/schema/query.ts | 14 +++- .../features/integration-zalo/api/public.ts | 11 ++- .../features/integrations/api/public/ai.ts | 4 + .../features/integrations/api/public/crud.ts | 4 + .../integrations/schema/ai-provider.ts | 22 ++++-- .../features/integrations/schema/public.ts | 2 +- .../src/features/personas/api/public.ts | 2 + .../src/handlers/message/incoming-message.ts | 73 ++++++++++++------- 26 files changed, 551 insertions(+), 167 deletions(-) diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 3bab864090..31891f0592 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -56,20 +56,6 @@ type SpecOperation = { const LEGACY_WORKSPACE_TOKEN_PATTERN = /workspace[_.]?token/i const LEGACY_API_SUFFIX_PATTERN = /[_.]api$/i -const DESCRIPTION_BACKLOG = new Set([ - "channels.", - "fbComments.", - "igComments.", - "igStories.", - "integrations.", - "messengerChannels.", - "messengerPersonas.", - "smtpIntegrations.", - "templateMessages.", - "webchats.", - "zaloChannels.", -]) - let operations: SpecOperation[] let responseSchemasByOperationId: Record let requestSchemasByOperationId: Record @@ -220,9 +206,6 @@ beforeAll(async () => { operations.sort((a, b) => a.operationId.localeCompare(b.operationId)) }, 120_000) -const isDescriptionBacklogged = (operationId: string): boolean => - DESCRIPTION_BACKLOG.has(operationId.slice(0, operationId.indexOf(".") + 1)) - const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/ const SUMMARY_STARTS_UPPERCASE_PATTERN = /^[A-Z]/ const normalizeDescriptionPhrase = (value: string): string => { @@ -275,18 +258,16 @@ describe("public API spec — operation naming guard", () => { expect(missingSummary).toEqual([]) }) - test("every non-backlogged operation has a description", () => { + test("every operation has a description", () => { const missingDescriptions = operations - .filter((operation) => !isDescriptionBacklogged(operation.operationId)) .filter((operation) => !operation.description) .map((operation) => operation.operationId) expect(missingDescriptions).toEqual([]) }) - test("every non-backlogged operation has a tag", () => { + test("every operation has a tag", () => { const missingTags = operations - .filter((operation) => !isDescriptionBacklogged(operation.operationId)) .filter((operation) => operation.tags.length === 0) .map((operation) => operation.operationId) @@ -328,12 +309,8 @@ describe("public API spec — operation naming guard", () => { expect(invalidDescriptions).toEqual([]) }) - test("every non-backlogged top-level input field has a description", () => { + test("every top-level input field has a description", () => { const missingInputDescriptions = operations.flatMap((operation) => { - if (isDescriptionBacklogged(operation.operationId)) { - return [] - } - const missingParameters = operation.parameters .filter( (parameter) => diff --git a/apps/builder/src/features/fb-comments/api/public.ts b/apps/builder/src/features/fb-comments/api/public.ts index 3004de57e3..260e4a6117 100644 --- a/apps/builder/src/features/fb-comments/api/public.ts +++ b/apps/builder/src/features/fb-comments/api/public.ts @@ -27,6 +27,8 @@ export const fbCommentsPublicRouter = { method: "GET", path: "/v1/fb-comments", summary: "List FB comment automations", + description: + "Use this to find automation ids before inspecting one with `fbComments.get` or changing one with `fbComments.update`. Returns automations in this workspace.", tags: ["FB Comments"], }) .input(listFbCommentsPublicRequest) @@ -46,6 +48,8 @@ export const fbCommentsPublicRouter = { method: "GET", path: "/v1/fb-comments/{id}", summary: "Get a specific FB comment automation", + description: + "Returns one automation's trigger and reply settings. Use `fbComments.list` to find its id first.", tags: ["FB Comments"], }) .input(getFbCommentPublicRequest) @@ -64,6 +68,8 @@ export const fbCommentsPublicRouter = { method: "POST", path: "/v1/fb-comments", summary: "Create an FB comment automation", + description: + "Adds an automation that replies to or hides comments on a Facebook post. Use `fbComments.listPosts` to find an eligible post first.", successStatus: 201, tags: ["FB Comments"], }) @@ -83,6 +89,8 @@ export const fbCommentsPublicRouter = { method: "PUT", path: "/v1/fb-comments/{id}", summary: "Update an FB comment automation", + description: + "Changes an existing automation's trigger or reply settings. Call `fbComments.get` to inspect current values first.", tags: ["FB Comments"], }) .input(updateFbCommentPublicRequest) @@ -101,6 +109,8 @@ export const fbCommentsPublicRouter = { method: "DELETE", path: "/v1/fb-comments/{id}", summary: "Delete an FB comment automation", + description: + "Permanently deletes an automation. Use `fbComments.list` to find its id first.", successStatus: 204, tags: ["FB Comments"], }) @@ -118,6 +128,8 @@ export const fbCommentsPublicRouter = { method: "GET", path: "/v1/fb-comments/facebook-posts", summary: "List Facebook posts eligible for FB comment automation", + description: + "Returns posts from the workspace's connected Facebook pages that `fbComments.create` can target.", tags: ["FB Comments"], }) .output(listFacebookPostsPublicResponse) diff --git a/apps/builder/src/features/fb-comments/schema/action.ts b/apps/builder/src/features/fb-comments/schema/action.ts index d8d13a9da2..5c3eac20a6 100644 --- a/apps/builder/src/features/fb-comments/schema/action.ts +++ b/apps/builder/src/features/fb-comments/schema/action.ts @@ -48,25 +48,59 @@ export const listFbCommentsResponse = z.object({ export type ListFbCommentsResponse = z.infer export const createFbCommentRequest = z.object({ - name: z.string().trim().min(1).max(255), - type: z.literal("messenger").default("messenger"), - folderId: zodBigintAsString().nullish(), - post: fbCommentPostSchema, - privateReply: fbCommentReplySchema, - publicReply: fbCommentReplySchema, - includeKeywords: fbCommentIncludeKeywordsSchema, - excludeKeywords: z.array(z.string()), - options: fbCommentOptionsSchema, - hideComments: fbCommentHideCommentsSchema, - replyAfter: fbCommentReplyAfterSchema, + name: z.string().trim().min(1).max(255).describe("Automation name."), + type: z + .literal("messenger") + .default("messenger") + .describe("Automation channel type."), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the automation in, or null for root-level."), + post: fbCommentPostSchema.describe( + "Facebook post to watch for comments. Get it from `fbComments.listPosts`.", + ), + privateReply: fbCommentReplySchema.describe( + "Private message reply sent to the commenter, if any.", + ), + publicReply: fbCommentReplySchema.describe( + "Public comment reply posted under the comment, if any.", + ), + includeKeywords: fbCommentIncludeKeywordsSchema.describe( + "Only trigger when the comment matches these keywords.", + ), + excludeKeywords: z + .array(z.string()) + .describe("Never trigger when the comment matches these keywords."), + options: fbCommentOptionsSchema.describe( + "Matching and trigger behavior options.", + ), + hideComments: fbCommentHideCommentsSchema.describe( + "Whether to hide matching comments after replying.", + ), + replyAfter: fbCommentReplyAfterSchema.describe( + "Delay before sending the reply.", + ), }) export type CreateFbCommentRequest = z.infer export const updateFbCommentRequest = createFbCommentRequest.partial().and( z.object({ - isActive: z.boolean().optional(), - startTime: z.string().nullable().optional(), - endTime: z.string().nullable().optional(), + isActive: z + .boolean() + .optional() + .describe("Whether the automation is enabled."), + startTime: z + .string() + .nullable() + .optional() + .describe( + "When the automation starts being active, or null for immediately.", + ), + endTime: z + .string() + .nullable() + .optional() + .describe("When the automation stops being active, or null for never."), }), ) export type UpdateFbCommentRequest = z.infer diff --git a/apps/builder/src/features/fb-comments/schema/public.ts b/apps/builder/src/features/fb-comments/schema/public.ts index 7b6de52253..5b0bae3cb1 100644 --- a/apps/builder/src/features/fb-comments/schema/public.ts +++ b/apps/builder/src/features/fb-comments/schema/public.ts @@ -7,10 +7,18 @@ import { facebookPostSchema, fbCommentResource } from "./resource" const sortSchema = z.array(z.object({ id: z.string(), desc: z.boolean() })) export const listFbCommentsPublicRequest = publicListRequest.extend({ - sort: sortSchema.optional(), - name: z.string().nullish(), - folderId: zodBigintAsString().nullish(), - isActive: z.boolean().nullish(), + sort: sortSchema.optional().describe("Sort order."), + name: z + .string() + .nullish() + .describe( + "Case-insensitive substring match against the automation's name.", + ), + folderId: zodBigintAsString().nullish().describe("Restrict to this folder."), + isActive: z + .boolean() + .nullish() + .describe("Restrict to enabled or disabled automations."), }) export const fbCommentPublicResource = fbCommentResource.omit({ @@ -24,15 +32,23 @@ export const listFbCommentsPublicResponse = publicListResponse( export const createFbCommentPublicRequest = createFbCommentRequest export const updateFbCommentPublicRequest = updateFbCommentRequest.and( - z.object({ id: zodBigintAsString() }), + z.object({ + id: zodBigintAsString().describe( + "FB comment automation id. Get it from `fbComments.list`.", + ), + }), ) export const getFbCommentPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "FB comment automation id. Get it from `fbComments.list`.", + ), }) export const deleteFbCommentPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "FB comment automation id. Get it from `fbComments.list`.", + ), }) export const listFacebookPostsPublicResponse = z.object({ diff --git a/apps/builder/src/features/ig-comments/api/public.ts b/apps/builder/src/features/ig-comments/api/public.ts index 46e6164514..32e909fe56 100644 --- a/apps/builder/src/features/ig-comments/api/public.ts +++ b/apps/builder/src/features/ig-comments/api/public.ts @@ -31,6 +31,8 @@ export const igCommentsPublicRouter = { method: "GET", path: "/v1/ig-comments", summary: "List Instagram comment automations", + description: + "Use this to find automation ids before inspecting one with `igComments.get` or changing one with `igComments.update`. Returns automations in this workspace.", tags: ["IG Comments"], }) .input(listIgCommentsPublicRequest) @@ -50,6 +52,8 @@ export const igCommentsPublicRouter = { method: "GET", path: "/v1/ig-comments/{id}", summary: "Get a specific Instagram comment automation", + description: + "Returns one automation's trigger and reply settings. Use `igComments.list` to find its id first.", tags: ["IG Comments"], }) .input(getIgCommentPublicRequest) @@ -68,6 +72,8 @@ export const igCommentsPublicRouter = { method: "POST", path: "/v1/ig-comments", summary: "Create an Instagram comment automation", + description: + "Adds an automation that replies to or hides comments on Instagram media. Use `igComments.listMedia` to find eligible media first.", successStatus: 201, tags: ["IG Comments"], }) @@ -88,6 +94,8 @@ export const igCommentsPublicRouter = { method: "PUT", path: "/v1/ig-comments/{id}", summary: "Update an Instagram comment automation", + description: + "Changes an existing automation's trigger or reply settings. Call `igComments.get` to inspect current values first.", tags: ["IG Comments"], }) .input(updateIgCommentPublicRequest) @@ -106,6 +114,8 @@ export const igCommentsPublicRouter = { method: "DELETE", path: "/v1/ig-comments/{id}", summary: "Delete an Instagram comment automation", + description: + "Permanently deletes an automation. Use `igComments.list` to find its id first.", successStatus: 204, tags: ["IG Comments"], }) @@ -123,6 +133,8 @@ export const igCommentsPublicRouter = { method: "GET", path: "/v1/ig-comments/instagram-media", summary: "List Instagram media eligible for IG comment automation", + description: + "Returns Instagram posts/reels from the workspace's connected accounts that `igComments.create` can target.", tags: ["IG Comments"], }) .input(listInstagramMediaPublicRequest) diff --git a/apps/builder/src/features/ig-comments/schema/action.ts b/apps/builder/src/features/ig-comments/schema/action.ts index b10ea78db6..8a18e0c0ad 100644 --- a/apps/builder/src/features/ig-comments/schema/action.ts +++ b/apps/builder/src/features/ig-comments/schema/action.ts @@ -52,25 +52,58 @@ export const igCommentVariants = igCommentAutomationTypes export type IgCommentVariant = z.infer export const createIgCommentRequest = z.object({ - name: z.string().trim().min(1).max(255), - type: igCommentVariants, - folderId: zodBigintAsString().nullish(), - post: fbCommentPostSchema, - privateReply: fbCommentReplySchema, - publicReply: fbCommentReplySchema, - includeKeywords: fbCommentIncludeKeywordsSchema, - excludeKeywords: z.array(z.string()), - options: fbCommentOptionsSchema, - hideComments: fbCommentHideCommentsSchema, - replyAfter: fbCommentReplyAfterSchema, + name: z.string().trim().min(1).max(255).describe("Automation name."), + type: igCommentVariants.describe( + "Instagram connection type, `instagram` (native login) or `instagramFacebook` (linked via a Facebook page).", + ), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the automation in, or null for root-level."), + post: fbCommentPostSchema.describe( + "Instagram media to watch for comments. Get it from `igComments.listMedia`.", + ), + privateReply: fbCommentReplySchema.describe( + "Private message reply sent to the commenter, if any.", + ), + publicReply: fbCommentReplySchema.describe( + "Public comment reply posted under the comment, if any.", + ), + includeKeywords: fbCommentIncludeKeywordsSchema.describe( + "Only trigger when the comment matches these keywords.", + ), + excludeKeywords: z + .array(z.string()) + .describe("Never trigger when the comment matches these keywords."), + options: fbCommentOptionsSchema.describe( + "Matching and trigger behavior options.", + ), + hideComments: fbCommentHideCommentsSchema.describe( + "Whether to hide matching comments after replying.", + ), + replyAfter: fbCommentReplyAfterSchema.describe( + "Delay before sending the reply.", + ), }) export type CreateIgCommentRequest = z.infer export const updateIgCommentRequest = createIgCommentRequest.partial().and( z.object({ - isActive: z.boolean().optional(), - startTime: z.string().nullable().optional(), - endTime: z.string().nullable().optional(), + isActive: z + .boolean() + .optional() + .describe("Whether the automation is enabled."), + startTime: z + .string() + .nullable() + .optional() + .describe( + "When the automation starts being active, or null for immediately.", + ), + endTime: z + .string() + .nullable() + .optional() + .describe("When the automation stops being active, or null for never."), }), ) export type UpdateIgCommentRequest = z.infer diff --git a/apps/builder/src/features/ig-comments/schema/public.ts b/apps/builder/src/features/ig-comments/schema/public.ts index 59a71f3245..cd4e30f6cd 100644 --- a/apps/builder/src/features/ig-comments/schema/public.ts +++ b/apps/builder/src/features/ig-comments/schema/public.ts @@ -8,10 +8,18 @@ import { igCommentResource } from "./resource" const sortSchema = z.array(z.object({ id: z.string(), desc: z.boolean() })) export const listIgCommentsPublicRequest = publicListRequest.extend({ - sort: sortSchema.optional(), - name: z.string().nullish(), - folderId: zodBigintAsString().nullish(), - isActive: z.boolean().nullish(), + sort: sortSchema.optional().describe("Sort order."), + name: z + .string() + .nullish() + .describe( + "Case-insensitive substring match against the automation's name.", + ), + folderId: zodBigintAsString().nullish().describe("Restrict to this folder."), + isActive: z + .boolean() + .nullish() + .describe("Restrict to enabled or disabled automations."), }) export const igCommentPublicResource = igCommentResource.omit({ @@ -25,19 +33,29 @@ export const listIgCommentsPublicResponse = publicListResponse( export const createIgCommentPublicRequest = createIgCommentRequest export const updateIgCommentPublicRequest = updateIgCommentRequest.and( - z.object({ id: zodBigintAsString() }), + z.object({ + id: zodBigintAsString().describe( + "Instagram comment automation id. Get it from `igComments.list`.", + ), + }), ) export const getIgCommentPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Instagram comment automation id. Get it from `igComments.list`.", + ), }) export const deleteIgCommentPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Instagram comment automation id. Get it from `igComments.list`.", + ), }) export const listInstagramMediaPublicRequest = z.object({ - variant: igCommentAutomationTypes, + variant: igCommentAutomationTypes.describe( + "Which Instagram connection type to list media from, `instagram` (native login) or `instagramFacebook` (linked via a Facebook page).", + ), }) export const listInstagramMediaPublicResponse = z.object({ diff --git a/apps/builder/src/features/ig-stories/api/public.ts b/apps/builder/src/features/ig-stories/api/public.ts index 3c7c0f80ee..82f110bc95 100644 --- a/apps/builder/src/features/ig-stories/api/public.ts +++ b/apps/builder/src/features/ig-stories/api/public.ts @@ -31,6 +31,8 @@ export const igStoriesPublicRouter = { method: "GET", path: "/v1/ig-stories", summary: "List Instagram Story Automations", + description: + "Use this to find automation ids before inspecting one with `igStories.get` or changing one with `igStories.update`. Returns automations in this workspace.", tags: ["IG Stories"], }) .input(listIgStoriesPublicRequest) @@ -50,6 +52,8 @@ export const igStoriesPublicRouter = { method: "GET", path: "/v1/ig-stories/{id}", summary: "Get a specific Instagram Story Automation", + description: + "Returns one automation's trigger and reply settings. Use `igStories.list` to find its id first.", tags: ["IG Stories"], }) .input(getIgStoryPublicRequest) @@ -68,6 +72,8 @@ export const igStoriesPublicRouter = { method: "POST", path: "/v1/ig-stories", summary: "Create Instagram Story Automation", + description: + "Adds an automation that replies to story mentions/replies on Instagram. Use `igStories.listStories` to find eligible stories first.", successStatus: 201, tags: ["IG Stories"], }) @@ -88,6 +94,8 @@ export const igStoriesPublicRouter = { method: "PUT", path: "/v1/ig-stories/{id}", summary: "Update Instagram Story Automation", + description: + "Changes an existing automation's trigger or reply settings. Call `igStories.get` to inspect current values first.", tags: ["IG Stories"], }) .input(updateIgStoryPublicRequest) @@ -106,6 +114,8 @@ export const igStoriesPublicRouter = { method: "DELETE", path: "/v1/ig-stories/{id}", summary: "Delete Instagram Story Automation", + description: + "Permanently deletes an automation. Use `igStories.list` to find its id first.", successStatus: 204, tags: ["IG Stories"], }) @@ -123,6 +133,8 @@ export const igStoriesPublicRouter = { method: "GET", path: "/v1/ig-stories/instagram-stories", summary: "List Instagram stories eligible for IG story automation", + description: + "Returns Instagram stories from the workspace's connected accounts that `igStories.create` can target.", tags: ["IG Stories"], }) .input(listInstagramStoriesPublicRequest) diff --git a/apps/builder/src/features/ig-stories/schema/action.ts b/apps/builder/src/features/ig-stories/schema/action.ts index f94999d8fc..62935def17 100644 --- a/apps/builder/src/features/ig-stories/schema/action.ts +++ b/apps/builder/src/features/ig-stories/schema/action.ts @@ -49,20 +49,43 @@ export const igStoryVariants = igStoryAutomationTypes export type IgStoryVariant = z.infer export const createIgStoryRequest = z.object({ - name: z.string().trim().min(1).max(255), - type: igStoryVariants, - folderId: zodBigintAsString().nullish(), - story: igStoryTargetSchema, - reply: fbCommentReplySchema, - includeKeywords: fbCommentIncludeKeywordsSchema, + name: z.string().trim().min(1).max(255).describe("Automation name."), + type: igStoryVariants.describe( + "Instagram connection type, `instagram` (native login) or `instagramFacebook` (linked via a Facebook page).", + ), + folderId: zodBigintAsString() + .nullish() + .describe("Folder to place the automation in, or null for root-level."), + story: igStoryTargetSchema.describe( + "Instagram story to watch for replies/mentions. Get it from `igStories.listStories`.", + ), + reply: fbCommentReplySchema.describe( + "Private message reply sent to the contact.", + ), + includeKeywords: fbCommentIncludeKeywordsSchema.describe( + "Only trigger when the reply matches these keywords.", + ), }) export type CreateIgStoryRequest = z.infer export const updateIgStoryRequest = createIgStoryRequest.partial().and( z.object({ - isActive: z.boolean().optional(), - startTime: z.string().nullable().optional(), - endTime: z.string().nullable().optional(), + isActive: z + .boolean() + .optional() + .describe("Whether the automation is enabled."), + startTime: z + .string() + .nullable() + .optional() + .describe( + "When the automation starts being active, or null for immediately.", + ), + endTime: z + .string() + .nullable() + .optional() + .describe("When the automation stops being active, or null for never."), }), ) export type UpdateIgStoryRequest = z.infer diff --git a/apps/builder/src/features/ig-stories/schema/public.ts b/apps/builder/src/features/ig-stories/schema/public.ts index 126db14867..359a68707c 100644 --- a/apps/builder/src/features/ig-stories/schema/public.ts +++ b/apps/builder/src/features/ig-stories/schema/public.ts @@ -6,9 +6,17 @@ import { createIgStoryRequest, updateIgStoryRequest } from "./action" import { igStoryResource } from "./resource" export const listIgStoriesPublicRequest = publicListRequest.extend({ - name: z.string().nullish(), - folderId: zodBigintAsString().nullish(), - isActive: z.boolean().nullish(), + name: z + .string() + .nullish() + .describe( + "Case-insensitive substring match against the automation's name.", + ), + folderId: zodBigintAsString().nullish().describe("Restrict to this folder."), + isActive: z + .boolean() + .nullish() + .describe("Restrict to enabled or disabled automations."), }) export const igStoryPublicResource = igStoryResource.omit({ workspaceId: true, @@ -18,19 +26,29 @@ export const listIgStoriesPublicResponse = publicListResponse( ) export const createIgStoryPublicRequest = createIgStoryRequest export const updateIgStoryPublicRequest = updateIgStoryRequest.and( - z.object({ id: zodBigintAsString() }), + z.object({ + id: zodBigintAsString().describe( + "Instagram story automation id. Get it from `igStories.list`.", + ), + }), ) export const getIgStoryPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Instagram story automation id. Get it from `igStories.list`.", + ), }) export const deleteIgStoryPublicRequest = z.object({ - id: zodBigintAsString(), + id: zodBigintAsString().describe( + "Instagram story automation id. Get it from `igStories.list`.", + ), }) export const listInstagramStoriesPublicRequest = z.object({ - variant: igStoryAutomationTypes, + variant: igStoryAutomationTypes.describe( + "Which Instagram connection type to list stories from, `instagram` (native login) or `instagramFacebook` (linked via a Facebook page).", + ), }) export const listInstagramStoriesPublicResponse = z.object({ diff --git a/apps/builder/src/features/integration-api/api/public.ts b/apps/builder/src/features/integration-api/api/public.ts index 8f39e6b6ff..827d537cbb 100644 --- a/apps/builder/src/features/integration-api/api/public.ts +++ b/apps/builder/src/features/integration-api/api/public.ts @@ -48,13 +48,21 @@ export const channelsPublicRouter = { method: "POST", path: "/v1/channels/api/typing", summary: "Notify ChatbotX that the contact is typing", + description: + "Records a typing indicator for the contact. Currently accepted and logged only; no downstream effect yet.", tags: ["API Channel"], successStatus: 204, }) .input( z.object({ - contact: z.object({ sourceId: z.string().min(1) }), - typing: z.boolean(), + contact: z + .object({ + sourceId: z.string().min(1).describe("Contact id in your system."), + }) + .describe("Contact who is typing."), + typing: z + .boolean() + .describe("Whether the contact started or stopped typing."), }), ) .errors(possibleErrorsOnCreatingResource) @@ -76,12 +84,18 @@ export const channelsPublicRouter = { method: "POST", path: "/v1/channels/api/read", summary: "Notify ChatbotX that the contact read our messages", + description: + "Marks the conversation as read up to this contact, mirroring a read receipt from the channel.", tags: ["API Channel"], successStatus: 204, }) .input( z.object({ - contact: z.object({ sourceId: z.string().min(1) }), + contact: z + .object({ + sourceId: z.string().min(1).describe("Contact id in your system."), + }) + .describe("Contact who read the messages."), }), ) .errors(possibleErrorsOnCreatingResource) @@ -111,10 +125,20 @@ export const channelsPublicRouter = { }) .input( z.object({ - messageId: z.string().min(1), - status: z.enum(["delivered", "failed", "read"]), - timestamp: z.string(), - error: z.unknown().optional(), + messageId: z + .string() + .min(1) + .describe( + "Id of the message being reported on, echoed back from the outbound callback.", + ), + status: z + .enum(["delivered", "failed", "read"]) + .describe("Delivery outcome for the message."), + timestamp: z.string().describe("When the status change occurred."), + error: z + .unknown() + .optional() + .describe("Provider error details, present when status is `failed`."), }), ) .errors(possibleErrorsOnCreatingResource) @@ -136,6 +160,8 @@ export const channelsPublicRouter = { method: "GET", path: "/v1/channels/api/me", summary: "Verify your token and echo the connected inbox identity", + description: + "Use this to confirm a token is valid and discover which inbox and workspace it is scoped to.", tags: ["API Channel"], }) .output( diff --git a/apps/builder/src/features/integration-messenger/api/public.ts b/apps/builder/src/features/integration-messenger/api/public.ts index 6236897d1e..35af97a7a6 100644 --- a/apps/builder/src/features/integration-messenger/api/public.ts +++ b/apps/builder/src/features/integration-messenger/api/public.ts @@ -12,9 +12,18 @@ export const messengerChannelsPublicRouter = { method: "PATCH", path: "/v1/messenger-channels/{id}/tag-sync", summary: "Enable or disable tag sync for a Messenger channel", + description: + "Toggles whether this Messenger channel's page tags sync into ChatbotX as contact tags.", tags: ["Channels"], }) - .input(z.object({ id: zodBigintAsString(), enabled: z.boolean() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Messenger channel (integration) id. Get it from `integrations.list`.", + ), + enabled: z.boolean().describe("Whether tag sync should be enabled."), + }), + ) .output(z.object({ syncTagEnabledAt: z.date().nullable() })) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { diff --git a/apps/builder/src/features/integration-smtp/api/public.ts b/apps/builder/src/features/integration-smtp/api/public.ts index 056363599f..4355e2a49a 100644 --- a/apps/builder/src/features/integration-smtp/api/public.ts +++ b/apps/builder/src/features/integration-smtp/api/public.ts @@ -45,6 +45,8 @@ export const smtpIntegrationsPublicRouter = { method: "GET", path: "/v1/smtp-integrations", summary: "List SMTP integrations", + description: + "Use this to find SMTP integration ids before inspecting one with `smtpIntegrations.get` or changing one with `smtpIntegrations.update`. Returns SMTP integrations in this workspace.", tags, }) .input(publicListRequest) @@ -62,9 +64,17 @@ export const smtpIntegrationsPublicRouter = { method: "GET", path: "/v1/smtp-integrations/{id}", summary: "Get an SMTP integration by id", + description: + "Returns one SMTP integration's settings, excluding the stored password. Use `smtpIntegrations.list` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "SMTP integration id. Get it from `smtpIntegrations.list`.", + ), + }), + ) .output(integrationSmtpResource) .errors(possibleErrorsOnFindingResource) .handler(async ({ context, input }) => @@ -81,6 +91,8 @@ export const smtpIntegrationsPublicRouter = { method: "POST", path: "/v1/smtp-integrations", summary: "Create an SMTP integration", + description: + "Connects an SMTP server for outbound email broadcasts. Use `smtpIntegrations.list` first to avoid duplicating an existing one.", successStatus: 201, tags, }) @@ -118,9 +130,19 @@ export const smtpIntegrationsPublicRouter = { method: "PUT", path: "/v1/smtp-integrations/{id}", summary: "Update an SMTP integration", + description: + "Changes an existing SMTP integration's settings, including its credentials. Call `smtpIntegrations.get` to inspect current values first.", tags, }) - .input(updateSmtpRequest.and(z.object({ id: zodBigintAsString() }))) + .input( + updateSmtpRequest.and( + z.object({ + id: zodBigintAsString().describe( + "SMTP integration id. Get it from `smtpIntegrations.list`.", + ), + }), + ), + ) .output(integrationSmtpResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { @@ -141,10 +163,18 @@ export const smtpIntegrationsPublicRouter = { method: "DELETE", path: "/v1/smtp-integrations/{id}", summary: "Delete an SMTP integration", + description: + "Disconnects an SMTP integration. Use `smtpIntegrations.list` to find its id first.", successStatus: 204, tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "SMTP integration id. Get it from `smtpIntegrations.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { const integration = await integrationSmtpService.findByIdForWorkspace({ diff --git a/apps/builder/src/features/integration-smtp/schema/mutation.ts b/apps/builder/src/features/integration-smtp/schema/mutation.ts index e0aba5f32c..6bbfb8e8c5 100644 --- a/apps/builder/src/features/integration-smtp/schema/mutation.ts +++ b/apps/builder/src/features/integration-smtp/schema/mutation.ts @@ -19,12 +19,27 @@ export const fromAddressSchema = z export const createSmtpRequest = z .object({ - provider: smtpProviders, - host: z.string(), - port: z.coerce.number().int().positive().max(65_535), - username: z.string().min(1).max(255), - password: z.string().min(1).max(255), - fromAddress: fromAddressSchema, + provider: smtpProviders.describe( + "SMTP provider preset, or `other` for a custom server.", + ), + host: z + .string() + .describe("SMTP server host. Required when provider is `other`."), + port: z.coerce + .number() + .int() + .positive() + .max(65_535) + .describe("SMTP server port. Required when provider is `other`."), + username: z + .string() + .min(1) + .max(255) + .describe("SMTP username, and the display name for outgoing broadcasts."), + password: z.string().min(1).max(255).describe("SMTP password."), + fromAddress: fromAddressSchema.describe( + "Sender email address, plain or 'Name ' format.", + ), }) .superRefine((data, ctx) => { if (data.provider === "other") { @@ -50,12 +65,25 @@ export type CreateSmtpRequest = z.infer export const updateSmtpRequest = z .object({ - provider: smtpProviders, - host: z.string(), - port: z.coerce.number().int().positive(), - username: z.string().min(1), - password: z.string().min(1), - fromAddress: fromAddressSchema, + provider: smtpProviders.describe( + "SMTP provider preset, or `other` for a custom server.", + ), + host: z + .string() + .describe("SMTP server host. Required when provider is `other`."), + port: z.coerce + .number() + .int() + .positive() + .describe("SMTP server port. Required when provider is `other`."), + username: z + .string() + .min(1) + .describe("SMTP username, and the display name for outgoing broadcasts."), + password: z.string().min(1).describe("SMTP password."), + fromAddress: fromAddressSchema.describe( + "Sender email address, plain or 'Name ' format.", + ), }) .superRefine((data, ctx) => { if (data.provider === "other") { diff --git a/apps/builder/src/features/integration-webchat/api/public.ts b/apps/builder/src/features/integration-webchat/api/public.ts index f6d3d444e9..dbfcda2988 100644 --- a/apps/builder/src/features/integration-webchat/api/public.ts +++ b/apps/builder/src/features/integration-webchat/api/public.ts @@ -37,6 +37,8 @@ export const webchatsPublicRouter = { method: "GET", path: "/v1/webchats", summary: "List webchats", + description: + "Use this to find webchat ids before inspecting one with `webchats.get` or changing one with `webchats.update`. Returns webchats in this workspace.", tags, }) .input(publicListRequest) @@ -56,9 +58,17 @@ export const webchatsPublicRouter = { method: "GET", path: "/v1/webchats/{id}", summary: "Get a webchat by id", + description: + "Returns one webchat's branding and behavior settings. Use `webchats.list` to find its id first.", tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Webchat id. Get it from `webchats.list`.", + ), + }), + ) .output(webchatPublicResource) .errors(possibleErrorsOnFindingResource) .handler( @@ -74,6 +84,8 @@ export const webchatsPublicRouter = { method: "POST", path: "/v1/webchats", summary: "Create a webchat", + description: + "Adds a webchat widget for the workspace's website. Use `webchats.list` first to avoid duplicating an existing one.", successStatus: 201, tags, }) @@ -112,10 +124,18 @@ export const webchatsPublicRouter = { method: "PUT", path: "/v1/webchats/{id}", summary: "Update a webchat", + description: + "Changes an existing webchat's branding or behavior settings. Call `webchats.get` to inspect current values first.", tags, }) .input( - updateWebchatPublicRequest.and(z.object({ id: zodBigintAsString() })), + updateWebchatPublicRequest.and( + z.object({ + id: zodBigintAsString().describe( + "Webchat id. Get it from `webchats.list`.", + ), + }), + ), ) .output(webchatPublicResource) .errors(possibleErrorsOnMutatingResource) @@ -151,10 +171,18 @@ export const webchatsPublicRouter = { method: "DELETE", path: "/v1/webchats/{id}", summary: "Delete a webchat", + description: + "Permanently deletes a webchat widget. Use `webchats.list` to find its id first.", successStatus: 204, tags, }) - .input(z.object({ id: zodBigintAsString() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Webchat id. Get it from `webchats.list`.", + ), + }), + ) .errors(possibleErrorsOnDeletingResource) .handler(async ({ context, input }) => { await integrationWebchatService.delete({ diff --git a/apps/builder/src/features/integration-webchat/schema/mutation.ts b/apps/builder/src/features/integration-webchat/schema/mutation.ts index 4026b37882..e97d05aa39 100644 --- a/apps/builder/src/features/integration-webchat/schema/mutation.ts +++ b/apps/builder/src/features/integration-webchat/schema/mutation.ts @@ -6,22 +6,51 @@ import { zodBigintAsString } from "@chatbotx.io/utils" import { z } from "zod" export const createWebchatRequest = z.object({ - name: z.string().min(1).max(40), + name: z.string().min(1).max(40).describe("Webchat display name."), workspaceId: zodBigintAsString().nullish(), - welcomeFlowId: zodBigintAsString().nullish(), - authorizedDomains: z.array( - z.object({ - value: z.hostname(), - }), - ), - conversationStarters: z.array(webchatConversationStarter), - persistentMenus: z.array(webchatPersistentMenu), - brandColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format"), - hideHeader: z.boolean().default(false), - showLogo: z.boolean().default(true), - hideMessageInput: z.boolean().default(false), - customCss: z.string().max(20_000).optional(), - enable: z.boolean().default(true), + welcomeFlowId: zodBigintAsString() + .nullish() + .describe( + "Flow to trigger when a visitor opens the widget, or null for none.", + ), + authorizedDomains: z + .array( + z.object({ + value: z.hostname(), + }), + ) + .describe("Domains allowed to embed this webchat widget."), + conversationStarters: z + .array(webchatConversationStarter) + .describe("Suggested opening messages shown to visitors."), + persistentMenus: z + .array(webchatPersistentMenu) + .describe("Quick-access menu items shown in the widget."), + brandColor: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format") + .describe("Widget accent color as a 6-digit hex code."), + hideHeader: z + .boolean() + .default(false) + .describe("Whether to hide the widget's header bar."), + showLogo: z + .boolean() + .default(true) + .describe("Whether to show the brand logo in the widget."), + hideMessageInput: z + .boolean() + .default(false) + .describe("Whether to hide the message input box."), + customCss: z + .string() + .max(20_000) + .optional() + .describe("Custom CSS applied to the widget."), + enable: z + .boolean() + .default(true) + .describe("Whether the webchat widget is active."), }) export type CreateWebchatRequest = z.infer diff --git a/apps/builder/src/features/integration-webchat/schema/public.ts b/apps/builder/src/features/integration-webchat/schema/public.ts index c877f8a43b..f4035e0ac9 100644 --- a/apps/builder/src/features/integration-webchat/schema/public.ts +++ b/apps/builder/src/features/integration-webchat/schema/public.ts @@ -23,7 +23,12 @@ export type WebchatPublicResource = z.infer export const createWebchatPublicRequest = createWebchatRequest .omit({ workspaceId: true, authorizedDomains: true }) - .extend({ authorizedDomains: z.array(z.hostname()).default([]) }) + .extend({ + authorizedDomains: z + .array(z.hostname()) + .default([]) + .describe("Domains allowed to embed this webchat widget."), + }) export type CreateWebchatPublicRequest = z.infer< typeof createWebchatPublicRequest > @@ -38,10 +43,22 @@ export type CreateWebchatPublicRequest = z.infer< export const updateWebchatPublicRequest = createWebchatPublicRequest .partial() .extend({ - hideHeader: z.boolean().optional(), - showLogo: z.boolean().optional(), - hideMessageInput: z.boolean().optional(), - enable: z.boolean().optional(), + hideHeader: z + .boolean() + .optional() + .describe("Whether to hide the widget's header bar."), + showLogo: z + .boolean() + .optional() + .describe("Whether to show the brand logo in the widget."), + hideMessageInput: z + .boolean() + .optional() + .describe("Whether to hide the message input box."), + enable: z + .boolean() + .optional() + .describe("Whether the webchat widget is active."), }) export type UpdateWebchatPublicRequest = z.infer< typeof updateWebchatPublicRequest diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/api/public.ts b/apps/builder/src/features/integration-whatsapp/message-templates/api/public.ts index 35f05b8f06..73637f9da7 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/api/public.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/api/public.ts @@ -14,6 +14,8 @@ export const templateMessagesPublicRouter = { method: "GET", path: "/v1/template-messages", summary: "List template messages", + description: + "Returns WhatsApp message templates approved for use in broadcasts, along with their approval status.", tags: ["Template Messages"], }) .input( diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/schema/query.ts b/apps/builder/src/features/integration-whatsapp/message-templates/schema/query.ts index d42cf0ec6d..8414ccdf91 100644 --- a/apps/builder/src/features/integration-whatsapp/message-templates/schema/query.ts +++ b/apps/builder/src/features/integration-whatsapp/message-templates/schema/query.ts @@ -6,9 +6,17 @@ import { whatsappMessageTemplateResource } from "./resource" export const listWhatsappMessageTemplatesRequest = z.object({ workspaceId: zodBigintAsString(), - inboxId: zodBigintAsString().optional(), - integrationWhatsappId: zodBigintAsString().optional(), - status: whatsappTemplateStatusSchema.optional(), + inboxId: zodBigintAsString() + .optional() + .describe( + "Restrict to templates connected to this inbox. Get it from `inboxes.list`.", + ), + integrationWhatsappId: zodBigintAsString() + .optional() + .describe("Restrict to templates on this WhatsApp integration."), + status: whatsappTemplateStatusSchema + .optional() + .describe("Restrict to templates with this approval status."), }) export type ListWhatsappMessageTemplatesRequest = z.infer< typeof listWhatsappMessageTemplatesRequest diff --git a/apps/builder/src/features/integration-zalo/api/public.ts b/apps/builder/src/features/integration-zalo/api/public.ts index eec52203fb..3436073d83 100644 --- a/apps/builder/src/features/integration-zalo/api/public.ts +++ b/apps/builder/src/features/integration-zalo/api/public.ts @@ -12,9 +12,18 @@ export const zaloChannelsPublicRouter = { method: "PATCH", path: "/v1/zalo-channels/{id}/tag-sync", summary: "Enable or disable tag sync for a Zalo channel", + description: + "Toggles whether this Zalo channel's tags sync into ChatbotX as contact tags.", tags: ["Channels"], }) - .input(z.object({ id: zodBigintAsString(), enabled: z.boolean() })) + .input( + z.object({ + id: zodBigintAsString().describe( + "Zalo channel (integration) id. Get it from `integrations.list`.", + ), + enabled: z.boolean().describe("Whether tag sync should be enabled."), + }), + ) .output(z.object({ syncTagEnabledAt: z.date().nullable() })) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { diff --git a/apps/builder/src/features/integrations/api/public/ai.ts b/apps/builder/src/features/integrations/api/public/ai.ts index 9b630b4433..4c083e8496 100644 --- a/apps/builder/src/features/integrations/api/public/ai.ts +++ b/apps/builder/src/features/integrations/api/public/ai.ts @@ -71,6 +71,8 @@ export const integrationsAiPublicRouter = { method: "GET", path: "/v1/integrations/ai/{provider}", summary: "Get an AI provider integration", + description: + "Returns one AI provider's connected model and settings, without exposing the stored API key. Use `integrations.connectAiProvider` to configure it.", tags: ["Integrations"], }) .input(getAiProviderRequest) @@ -129,6 +131,8 @@ export const integrationsAiPublicRouter = { method: "DELETE", path: "/v1/integrations/ai/{provider}", summary: "Disconnect an AI provider integration", + description: + "Removes the stored API key and configuration for an AI provider integration.", tags: ["Integrations"], successStatus: 204, }) diff --git a/apps/builder/src/features/integrations/api/public/crud.ts b/apps/builder/src/features/integrations/api/public/crud.ts index 390f49d634..9e519cebb4 100644 --- a/apps/builder/src/features/integrations/api/public/crud.ts +++ b/apps/builder/src/features/integrations/api/public/crud.ts @@ -24,6 +24,8 @@ export const integrationsCrudPublicRouter = { method: "GET", path: "/v1/integrations", summary: "List integrations", + description: + "Use this to find integration ids before inspecting one with `integrations.get`. Returns integrations in this workspace.", tags: ["Integrations"], }) .input(publicListRequest) @@ -41,6 +43,8 @@ export const integrationsCrudPublicRouter = { method: "GET", path: "/v1/integrations/{id}", summary: "Get an integration", + description: + "Returns one integration's connection status and settings. Use `integrations.list` to find its id first.", tags: ["Integrations"], }) .input(getIntegrationRequest) diff --git a/apps/builder/src/features/integrations/schema/ai-provider.ts b/apps/builder/src/features/integrations/schema/ai-provider.ts index 611a232d5a..5412b69545 100644 --- a/apps/builder/src/features/integrations/schema/ai-provider.ts +++ b/apps/builder/src/features/integrations/schema/ai-provider.ts @@ -9,7 +9,7 @@ export const aiProviderPathParam = z.enum([ export type AiProviderPathParam = z.infer export const getAiProviderRequest = z.object({ - provider: aiProviderPathParam, + provider: aiProviderPathParam.describe("AI provider identifier."), }) // Never includes `auth` — the encrypted/secret credential. Only `hasApiKey` @@ -25,8 +25,20 @@ export const publicAiProviderResource = z.object({ }) export const connectAiProviderRequest = z.object({ - apiKey: z.string().min(1), - model: z.string().min(1), - temperature: z.coerce.number().min(0).max(2), - maxOutputTokens: z.coerce.number().int().min(1).max(8192), + apiKey: z.string().min(1).describe("Provider API key."), + model: z + .string() + .min(1) + .describe("Model identifier to use for this provider."), + temperature: z.coerce + .number() + .min(0) + .max(2) + .describe("Sampling temperature."), + maxOutputTokens: z.coerce + .number() + .int() + .min(1) + .max(8192) + .describe("Maximum tokens generated per response."), }) diff --git a/apps/builder/src/features/integrations/schema/public.ts b/apps/builder/src/features/integrations/schema/public.ts index 92becba97f..a498a5080d 100644 --- a/apps/builder/src/features/integrations/schema/public.ts +++ b/apps/builder/src/features/integrations/schema/public.ts @@ -1,7 +1,7 @@ import { z } from "zod" export const getIntegrationRequest = z.object({ - id: z.string(), + id: z.string().describe("Integration id. Get it from `integrations.list`."), }) export const tokenRefreshErrorChannel = z.enum([ diff --git a/apps/builder/src/features/personas/api/public.ts b/apps/builder/src/features/personas/api/public.ts index 2de52911fe..6f0e72a14d 100644 --- a/apps/builder/src/features/personas/api/public.ts +++ b/apps/builder/src/features/personas/api/public.ts @@ -11,6 +11,8 @@ export const messengerPersonasPublicRouter = { method: "GET", path: "/v1/messenger-personas", summary: "List Messenger personas across the workspace's pages", + description: + "Returns available Messenger personas from every connected page, used to set which persona a message is sent as.", tags: ["Channels"], }) .output(listMessengerPersonasResponse) diff --git a/integrations/api/src/handlers/message/incoming-message.ts b/integrations/api/src/handlers/message/incoming-message.ts index 22933a989c..9a7773afce 100644 --- a/integrations/api/src/handlers/message/incoming-message.ts +++ b/integrations/api/src/handlers/message/incoming-message.ts @@ -16,32 +16,53 @@ import { z } from "zod" * builder's own request validation. */ export const incomingApiMessageSchema = z.object({ - contact: z.object({ - sourceId: z.string().min(1), - firstName: z.string().optional(), - lastName: z.string().optional(), - email: z.email().optional(), - phoneNumber: z.string().optional(), - avatar: z.url().optional(), - locale: z.string().optional(), - }), - message: z.object({ - sourceId: z.string().min(1), - text: z.string().nullish(), - attachments: z - .array( - z.object({ - url: z.url(), - fileType: fileTypes, - mimeType: z.string(), - name: z.string().optional(), - }), - ) - .optional(), - contentType: z.enum(["text", "location"]).default("text"), - contentAttributes: z.record(z.string(), z.unknown()).optional(), - }), - postbackPayload: z.string().nullish(), + contact: z + .object({ + sourceId: z + .string() + .min(1) + .describe( + "Stable contact id in your system; used to find or create the contact.", + ), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.email().optional(), + phoneNumber: z.string().optional(), + avatar: z.url().optional(), + locale: z.string().optional(), + }) + .describe( + "Contact the message is from, identified by your system's own id.", + ), + message: z + .object({ + sourceId: z + .string() + .min(1) + .describe( + "Idempotency key: resending the same value for the same contact does not duplicate the message.", + ), + text: z.string().nullish(), + attachments: z + .array( + z.object({ + url: z.url(), + fileType: fileTypes, + mimeType: z.string(), + name: z.string().optional(), + }), + ) + .optional(), + contentType: z.enum(["text", "location"]).default("text"), + contentAttributes: z.record(z.string(), z.unknown()).optional(), + }) + .describe("Message body and any attachments."), + postbackPayload: z + .string() + .nullish() + .describe( + "Payload echoed back when the message is a reply to a button/postback.", + ), }) export type IncomingApiMessage = z.infer From 997943596110bfb03c518f077a95ecf196b68b0f Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Tue, 15 Sep 2026 20:59:43 +0700 Subject: [PATCH 38/38] fix(api,flow-config,mcp): address PR #1184 review remediation Fixes correctness bugs found in code review: missing successStatus:204 on the email-topics delete route, publishFlowRequest/updateDraftFlowRequest silently discarding `spec` when a mixed body matches the graph union branch first, capabilities list limit silently clamped to 50 instead of the documented 200, and the flow compiler allowing goto self-loop edges plus failing to resolve a step's own id for a same-step "back to menu" goto. Adds guard tests for each and closes the docs gap for the new flow-spec DSL and capability discovery surfaces. Co-Authored-By: Claude Sonnet 5 --- .agents/skills/orpc-api/SKILL.md | 68 ++++++++ .../__tests__/flows-public-api.test.ts | 103 ++++++++++++ .../__tests__/public-spec-operations.test.ts | 59 +++++++ .../appointment-calendars/api/public.ts | 2 + .../src/features/contact-notes/api/public.ts | 3 + .../contacts/api/public/custom-fields.ts | 1 + .../src/features/email-topics/api/public.ts | 1 + apps/builder/src/features/flows/api/public.ts | 3 + .../src/features/flows/schema/action.ts | 89 +++++++--- .../src/features/products/api/public.ts | 1 + .../src/features/questionnaires/api/public.ts | 2 + .../src/features/sequences/api/public.ts | 1 + .../__tests__/openapi-loader.test.ts | 34 ++++ apps/mcp-server/__tests__/sse-server.test.ts | 27 +++ apps/mcp-server/src/openapi-loader.ts | 13 +- docs/flows.md | 157 ++++++++++++++++++ .../__tests__/capabilities-service.test.ts | 153 +++++++++++++++++ packages/business/src/capabilities/service.ts | 135 ++++++++++++--- .../__tests__/authoring/compile.test.ts | 86 ++++++++++ packages/flow-config/src/authoring/compile.ts | 55 +++++- packages/flow-config/src/authoring/errors.ts | 1 + 21 files changed, 941 insertions(+), 53 deletions(-) diff --git a/.agents/skills/orpc-api/SKILL.md b/.agents/skills/orpc-api/SKILL.md index f2f4eed7e9..0978c85c20 100644 --- a/.agents/skills/orpc-api/SKILL.md +++ b/.agents/skills/orpc-api/SKILL.md @@ -217,6 +217,74 @@ guidance (valid values, example payloads, edge cases) an LLM needs to pick the right tool and fill it in correctly. A `findByCustomField`-style endpoint with ambiguous input shape should always set `description`. +**Every DELETE (and any other body-less mutation) declares `successStatus: +204`** — a handler with no `.output(...)` returns `undefined`, and without +an explicit `successStatus` oRPC defaults to `200` with an empty body, which +misrepresents the response. `apps/builder/__tests__/public-spec-operations.test.ts` +enforces this across the whole public spec: every operation whose generated +response has no declared body schema must document `successStatus: 204` +(and nothing else in the 2xx range). If a mutation's handler actually +`return`s data, add `.output(...)` instead of `successStatus: 204` — don't +declare a body-less success status on a route that has a body. + +```typescript + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/my-feature/{id}", + summary: "Delete item", + tags: ["MyFeature"], + successStatus: 204, + }) + .input(z.object({ id: zodBigintAsString() })) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await myFeatureService.delete({ id: input.id, workspaceId: context.workspace.id }) + }), +``` + +**`mcpSpec`/`x-mcp` controls MCP-specific tool metadata** beyond what +`summary`/`description`/`tags` cover — visibility in `tools/list`, whether a +token-scope check can be bypassed so the tool is discoverable even when the +caller lacks its scope, and the MCP read-only/destructive/idempotent hints. +Import `mcpSpec` from `@/lib/orpc/mcp-annotations` and pass it as the +function form of `.route({ spec: ... })` — **never a plain object**, which +the OpenAPI generator treats as a full replacement of the generated +operation (dropping `parameters`/`requestBody`/`responses`) rather than a +merge: + +```typescript +import { mcpSpec } from "@/lib/orpc/mcp-annotations" + + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/my-feature", + summary: "List items", + tags: ["MyFeature"], + spec: mcpSpec({ visibility: "default" }), + }) + // ... +``` + +- `visibility: "default"`** ships the tool in `tools/list` on every MCP + connection. The polarity is inverted from what you'd expect: **omitting + `spec` entirely (or `visibility` absent from it) means `"hidden"`** — + reachable only through the `search_tools`/`call_tool` meta-tools, not the + default connection payload. We cannot ask every one of the ~350 public + operations to opt out individually, so only the ~40 operations an agent + needs on every connection (list/get the most common resources, publish a + flow, etc.) opt IN. +- `alwaysVisible: true` exempts an operation from scope-based `tools/list` + filtering — reserved for the small set of discovery endpoints + (`capabilities.get`, `token.get`) a token must be able to *see* even when + it lacks the scope those endpoints themselves require, so the caller gets + a 403 body instead of the tool silently disappearing. +- `readOnlyHint`/`destructiveHint`/`idempotentHint` override the mcp-server + loader's HTTP-method-based inference (GET ⇒ read-only/idempotent, DELETE ⇒ + destructive/idempotent, everything else ⇒ none) when an operation doesn't + fit that default. + **`include`/`withCount` convention for list endpoints**: a public list endpoint whose row shape has optional relations or an expensive count query should accept `include?: string[]` (narrows the response payload — see diff --git a/apps/builder/__tests__/flows-public-api.test.ts b/apps/builder/__tests__/flows-public-api.test.ts index 681e7026ea..cf2863958d 100644 --- a/apps/builder/__tests__/flows-public-api.test.ts +++ b/apps/builder/__tests__/flows-public-api.test.ts @@ -465,6 +465,109 @@ describe("GET /v1/flows/{id}/versions", () => { }) }) +describe("publishFlowRequest / updateDraftFlowRequest — mixed body rejection", () => { + const validSpec = { + formatVersion: 1, + name: "Spec flow", + steps: [{ type: "send", text: "Hello!" }], + } + + test("publishFlowRequest rejects a body carrying both nodes/edges and spec", async () => { + const { publishFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = publishFlowRequest.safeParse({ + nodes: [], + edges: [], + spec: validSpec, + }) + + expect(result.success).toBe(false) + }) + + test("publishFlowRequest still accepts a spec-only body", async () => { + const { publishFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = publishFlowRequest.safeParse({ spec: validSpec }) + + expect(result.success).toBe(true) + expect(result.success && "spec" in result.data).toBe(true) + }) + + test("publishFlowRequest still accepts a graph-only body", async () => { + const { publishFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = publishFlowRequest.safeParse({ nodes: [], edges: [] }) + + expect(result.success).toBe(true) + expect(result.success && "spec" in result.data).toBe(false) + }) + + test("updateDraftFlowRequest rejects a body carrying both nodes/edges and spec", async () => { + const { updateDraftFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = updateDraftFlowRequest.safeParse({ + nodes: [], + edges: [], + spec: validSpec, + }) + + expect(result.success).toBe(false) + }) + + test("updateDraftFlowRequest still accepts a spec-only body", async () => { + const { updateDraftFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = updateDraftFlowRequest.safeParse({ spec: validSpec }) + + expect(result.success).toBe(true) + expect(result.success && "spec" in result.data).toBe(true) + }) + + test("updateDraftFlowRequest still accepts a graph-only body", async () => { + const { updateDraftFlowRequest } = await import( + "@/features/flows/schema/action" + ) + + const result = updateDraftFlowRequest.safeParse({ nodes: [], edges: [] }) + + expect(result.success).toBe(true) + expect(result.success && "spec" in result.data).toBe(false) + }) + + // The actual route input is `publishFlowRequest.and(publicIdParam(...))` — + // a plain `.strict()` union member rejects the intersection's `id` key + // before the two schemas ever merge, which would break every real + // request. Pin the composed shape directly so a future refactor can't + // silently reintroduce that regression. + test("publishFlowRequest composed with the route's id param still accepts spec-only and graph-only bodies", async () => { + const { publishFlowRequest } = await import( + "@/features/flows/schema/action" + ) + const { publicIdParam } = await import("@/lib/public-api/params") + + const composed = publishFlowRequest.and(publicIdParam("flow", "flows.list")) + + expect(composed.safeParse({ id: "1", spec: validSpec }).success).toBe(true) + expect(composed.safeParse({ id: "1", nodes: [], edges: [] }).success).toBe( + true, + ) + expect( + composed.safeParse({ id: "1", nodes: [], edges: [], spec: validSpec }) + .success, + ).toBe(false) + }) +}) + describe("POST /v1/flows/import", () => { const procedure = findProcedure("POST", "/v1/flows/import") diff --git a/apps/builder/__tests__/public-spec-operations.test.ts b/apps/builder/__tests__/public-spec-operations.test.ts index 31891f0592..3c9204ee53 100644 --- a/apps/builder/__tests__/public-spec-operations.test.ts +++ b/apps/builder/__tests__/public-spec-operations.test.ts @@ -555,6 +555,65 @@ describe("public API spec — error response coverage", () => { expect(missing404).toEqual([]) }) + // A route with no `.output(...)` schema serializes to this exact + // "unknown value" JSON Schema shape (oRPC/Zod's representation of `any`), + // distinct from every real response schema (which always declares a type + // or a real union of typed alternatives). + const isUndeclaredBodySchema = (schema: unknown): boolean => + JSON.stringify(schema) === JSON.stringify({ anyOf: [{}, { not: {} }] }) + + test("every operation with no declared response body documents successStatus: 204", () => { + const bodyless = operations.filter((op) => + isUndeclaredBodySchema(responseSchemasByOperationId[op.operationId]), + ) + + expect(bodyless.length).toBeGreaterThan(0) + + const wrongStatus = bodyless + .filter((op) => { + const successStatuses = op.responseStatuses.filter((status) => + status.startsWith("2"), + ) + return !(successStatuses.length === 1 && successStatuses[0] === "204") + }) + .map((op) => op.operationId) + + expect(wrongStatus).toEqual([]) + }) + + // Mirrors `toSnakeCase` in apps/mcp-server/src/openapi-loader.ts — kept in + // sync manually rather than imported, since apps/builder has no dependency + // on chatbotx-mcp-server. If that implementation changes, update this too. + const toSnakeCase = (str: string): string => + str + .replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, "_$1") + .replace(/([a-z\d])([A-Z])/g, "$1_$2") + .toLowerCase() + .replace(/[.\-\s]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + + test("operationIds are injective after MCP's snake_case conversion — a collision leaves one tool permanently unreachable", () => { + const snakeCased = operations.map((op) => toSnakeCase(op.operationId)) + const seen = new Map() + for (const [index, name] of snakeCased.entries()) { + const operationId = operations[index]?.operationId ?? "" + const existing = seen.get(name) + if (existing) { + existing.push(operationId) + } else { + seen.set(name, [operationId]) + } + } + + const collisions = [...seen.entries()].filter( + ([, operationIds]) => operationIds.length > 1, + ) + + expect(collisions).toEqual([]) + expect(new Set(snakeCased).size).toBe(operations.length) + }) + test("every POST/PUT/PATCH operation documents 422", () => { const bodyMethods = operations.filter( (op) => diff --git a/apps/builder/src/features/appointment-calendars/api/public.ts b/apps/builder/src/features/appointment-calendars/api/public.ts index 51c9f693e2..ca86af854f 100644 --- a/apps/builder/src/features/appointment-calendars/api/public.ts +++ b/apps/builder/src/features/appointment-calendars/api/public.ts @@ -101,6 +101,7 @@ export const appointmentCalendarsPublicRouter = { description: "Replaces an existing calendar's full configuration (duration, availability, buffers, reminders). Call `appointmentCalendars.get` to inspect current values first.", tags, + successStatus: 204, }) .input( updateAppointmentCalendarRequest.and(appointmentCalendarIdPublicRequest), @@ -124,6 +125,7 @@ export const appointmentCalendarsPublicRouter = { description: "Toggles whether a calendar accepts new bookings via `appointments.book`, without changing its configuration.", tags, + successStatus: 204, }) .input( setAppointmentCalendarActivePublicRequest.and( diff --git a/apps/builder/src/features/contact-notes/api/public.ts b/apps/builder/src/features/contact-notes/api/public.ts index 0854ffe137..d108f14683 100644 --- a/apps/builder/src/features/contact-notes/api/public.ts +++ b/apps/builder/src/features/contact-notes/api/public.ts @@ -6,6 +6,7 @@ import { listContactNotesPublicResponse, updateContactNotePublicRequest, } from "@/features/contact-notes/schema/public" +import { contactNoteResource } from "@/features/contact-notes/schema/resource" import { possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, @@ -71,6 +72,7 @@ export const contactsNotesPublicRouter = { }), ), ) + .output(contactNoteResource) // Mutating, not creating: the note is new, but `{identifier}` is resolved // via `contactService.resolveIdByIdentifier`, which throws a 404 when the // contact does not exist — so this route must declare `notFound` too. @@ -111,6 +113,7 @@ export const contactsNotesPublicRouter = { }), ), ) + .output(contactNoteResource) .errors(possibleErrorsOnMutatingResource) .handler(async ({ context, input }) => { const workspaceId = context.workspace.id diff --git a/apps/builder/src/features/contacts/api/public/custom-fields.ts b/apps/builder/src/features/contacts/api/public/custom-fields.ts index 7fc23a21f3..92be5d1c01 100644 --- a/apps/builder/src/features/contacts/api/public/custom-fields.ts +++ b/apps/builder/src/features/contacts/api/public/custom-fields.ts @@ -103,6 +103,7 @@ export const contactsCustomFieldsPublicRouter = { summary: "Set contact custom field value", description: "Changes one custom-field value on a resolved contact without altering its other fields. Use `contacts.listCustomFields` to inspect current values, or `contacts.setCustomFields` for several changes.", + successStatus: 204, tags: ["Contacts"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/email-topics/api/public.ts b/apps/builder/src/features/email-topics/api/public.ts index bff530d91e..55d0e6611d 100644 --- a/apps/builder/src/features/email-topics/api/public.ts +++ b/apps/builder/src/features/email-topics/api/public.ts @@ -122,6 +122,7 @@ export const emailTopicsPublicRouter = { description: "Permanently deletes an email topic. Use `emailTopics.list` to find its id first.", tags: ["EmailTopics"], + successStatus: 204, }) .input( z.object({ diff --git a/apps/builder/src/features/flows/api/public.ts b/apps/builder/src/features/flows/api/public.ts index cf03d6a0c5..1fafec6578 100644 --- a/apps/builder/src/features/flows/api/public.ts +++ b/apps/builder/src/features/flows/api/public.ts @@ -120,6 +120,7 @@ export const flowsPublicRouter = { summary: "Update flow settings", description: "Partially updates a flow's name, active, or enableInInbox flags.", + successStatus: 204, tags: ["Flows"], }) .input(updateFlowSchema.and(publicIdParam("flow", "flows.list"))) @@ -176,6 +177,7 @@ export const flowsPublicRouter = { summary: "Publish a flow", description: "Creates an immutable version from a draft and synchronizes the draft to match. Call `flows.validate` before this when supplying a spec, or use `flows.updateDraft` to save changes without publishing.", + successStatus: 204, tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) @@ -220,6 +222,7 @@ export const flowsPublicRouter = { summary: "Update a flow's draft version", description: "Overwrites the draft version's nodes/edges in place, without publishing. Accepts either the raw `{ nodes, edges }` graph the builder UI sends, or `{ spec }` compiled server-side into that same graph — draft nodes are not otherwise validated (see `flows.validate` to check a spec before writing it).", + successStatus: 204, tags: ["Flows"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/builder/src/features/flows/schema/action.ts b/apps/builder/src/features/flows/schema/action.ts index f65948bce5..d55cd372d1 100644 --- a/apps/builder/src/features/flows/schema/action.ts +++ b/apps/builder/src/features/flows/schema/action.ts @@ -28,45 +28,90 @@ export const updateFlowSchema = z.object({ }) export type UpdateFlowSchema = z.infer -export const updateDraftFlowVersionSchema = z.object({ - nodes: z - .array(z.any()) - .describe("Raw flow node graph, as sent by the builder UI."), - edges: z - .array(edgeSchema) - .describe("Raw flow edge graph, as sent by the builder UI."), -}) +// `publishFlowRequest`/`updateDraftFlowRequest` also intersect this schema +// with `publicIdParam(...)` (`.and()`) at the route, so this cannot be +// `.strict()` — zod validates each union branch against the raw input +// before merging, and a strict branch would reject the legitimate `id` key +// the intersection adds. `z.preprocess` runs on the *raw*, unparsed input +// (before the wrapped schema strips unknown keys), so it can see and reject +// a disallowed sibling key without changing the wrapped schema's own +// inferred output type — unlike `.catchall()` + `.superRefine()`, which +// widens every field's type to include the catchall's `unknown`. +const rejectIfKeysPresent = ( + keys: readonly string[], + label: string, + schema: T, +): T => + z.preprocess((raw, ctx) => { + if (raw && typeof raw === "object") { + const present = keys.filter((key) => key in raw) + if (present.length > 0) { + ctx.addIssue({ + code: "custom", + message: `Cannot combine ${present.map((key) => `"${key}"`).join("/")} with ${label} in the same request body.`, + }) + return z.NEVER + } + } + return raw + }, schema) as unknown as T + +export const updateDraftFlowVersionSchema = rejectIfKeysPresent( + ["spec"], + '"nodes"/"edges"', + z.object({ + nodes: z + .array(z.any()) + .describe("Raw flow node graph, as sent by the builder UI."), + edges: z + .array(edgeSchema) + .describe("Raw flow edge graph, as sent by the builder UI."), + }), +) export type UpdateDraftFlowVersionSchema = z.infer< typeof updateDraftFlowVersionSchema > /** `{ spec }` input, accepted by `flows.publish`/`flows.updateDraft` alongside the raw `{ nodes, edges }` shape, and the sole input of `flows.validate`. */ -export const flowSpecRequest = z.object({ - spec: flowSpecSchema, -}) +export const flowSpecRequest = rejectIfKeysPresent( + ["nodes", "edges"], + '"spec"', + z.object({ + spec: flowSpecSchema, + }), +) +// `flowSpecRequest` is listed first so a body that happens to satisfy both +// shapes resolves to the spec branch; the `rejectIfKeysPresent` guards on +// every branch are the primary protection — a mixed `{ spec, nodes, edges }` +// body fails all of them and zod reports the union mismatch as a 422 +// instead of silently discarding `spec` (or `nodes`/`edges`). /** Draft update accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored. */ export const updateDraftFlowRequest = z.union([ - updateDraftFlowVersionSchema, flowSpecRequest, + updateDraftFlowVersionSchema, ]) // Channel rules are declared per step (see // `@chatbotx.io/flow-config/channel-rules`), so this stays one generic hook // instead of accumulating a refinement per channel/step pair. -export const publishFlowSchema = z.object({ - nodes: z - .array(flowVersionSchema) - .superRefine(refineStepsByChannel) - .describe("Raw flow node graph, as sent by the builder UI."), - edges: z - .array(edgeSchema) - .describe("Raw flow edge graph, as sent by the builder UI."), -}) +export const publishFlowSchema = rejectIfKeysPresent( + ["spec"], + '"nodes"/"edges"', + z.object({ + nodes: z + .array(flowVersionSchema) + .superRefine(refineStepsByChannel) + .describe("Raw flow node graph, as sent by the builder UI."), + edges: z + .array(edgeSchema) + .describe("Raw flow edge graph, as sent by the builder UI."), + }), +) export type PublishFlowSchema = z.infer /** Publish accepts either the raw graph the builder UI sends, or a `{ spec }` an agent authored — compiled server-side into the same graph shape before publishing. */ -export const publishFlowRequest = z.union([publishFlowSchema, flowSpecRequest]) +export const publishFlowRequest = z.union([flowSpecRequest, publishFlowSchema]) // Reuse the package-level node union so client-side publish validation can // never drift from the server-side `publishFlowSchema` when node types are added. diff --git a/apps/builder/src/features/products/api/public.ts b/apps/builder/src/features/products/api/public.ts index 5c59d45db2..db1756d87f 100644 --- a/apps/builder/src/features/products/api/public.ts +++ b/apps/builder/src/features/products/api/public.ts @@ -92,6 +92,7 @@ export const productsPublicRouter = { summary: "Replace a product", description: "Fully replaces the product, including its variant options, variants, and addons.", + successStatus: 204, tags: ["Products"], }) .input( diff --git a/apps/builder/src/features/questionnaires/api/public.ts b/apps/builder/src/features/questionnaires/api/public.ts index 48d6263d1e..d6bd52d56f 100644 --- a/apps/builder/src/features/questionnaires/api/public.ts +++ b/apps/builder/src/features/questionnaires/api/public.ts @@ -98,6 +98,7 @@ export const questionnairesPublicRouter = { summary: "Update a questionnaire", description: "Replaces an existing questionnaire's questions and settings. Call `questionnaires.get` to inspect current values first.", + successStatus: 204, tags: ["Questionnaires"], }) .input(updateQuestionnairePublicRequest) @@ -142,6 +143,7 @@ export const questionnairesPublicRouter = { summary: "Rename a questionnaire", description: "Changes a questionnaire's display name without touching its questions.", + successStatus: 204, tags: ["Questionnaires"], }) .input(renameQuestionnairePublicRequest) diff --git a/apps/builder/src/features/sequences/api/public.ts b/apps/builder/src/features/sequences/api/public.ts index 2511246606..375b4e286e 100644 --- a/apps/builder/src/features/sequences/api/public.ts +++ b/apps/builder/src/features/sequences/api/public.ts @@ -100,6 +100,7 @@ export const sequencesPublicRouter = { summary: "Update a sequence's name or active state", description: "Changes a sequence name or active state without replacing its steps. Call `sequences.get` to inspect the current sequence, or use `sequences.list` to resolve its id.", + successStatus: 204, tags: ["Sequences"], spec: mcpSpec({ visibility: "default" }), }) diff --git a/apps/mcp-server/__tests__/openapi-loader.test.ts b/apps/mcp-server/__tests__/openapi-loader.test.ts index 4d54386612..f1a32057aa 100644 --- a/apps/mcp-server/__tests__/openapi-loader.test.ts +++ b/apps/mcp-server/__tests__/openapi-loader.test.ts @@ -95,6 +95,40 @@ describe("loadOpenApiSpec", () => { expect(tools.map((tool) => tool.name)).toEqual(["tags_list"]) }) + test("a snake_case name collision keeps only the first operation in the returned list, getCachedTools, AND getToolByName — never advertising a tool tools/call can't reach", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + servers: [{ url: "https://api.example.com" }], + paths: { + "/v1/ai-mcp-servers": { + get: { operationId: "aiMCPServers.list", summary: "First" }, + }, + "/v1/ai-mcpservers": { + get: { operationId: "aiMcpservers.list", summary: "Second" }, + }, + }, + }), + }) as unknown as typeof fetch + + const { loadOpenApiSpec, getCachedTools, getToolByName } = await import( + "../src/openapi-loader" + ) + const tools = await loadOpenApiSpec() + + // Both operationIds snake_case to "ai_mcpservers_list" — only the first + // survives, and it must survive identically everywhere a caller can + // read the tool list from. + expect( + tools.filter((tool) => tool.name === "ai_mcpservers_list"), + ).toHaveLength(1) + expect( + getCachedTools().filter((tool) => tool.name === "ai_mcpservers_list"), + ).toHaveLength(1) + expect(getToolByName("ai_mcpservers_list")?.description).toBe("First") + }) + test("joins summary and description into one tool description", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/apps/mcp-server/__tests__/sse-server.test.ts b/apps/mcp-server/__tests__/sse-server.test.ts index 72f5a25709..6d761b6e6c 100644 --- a/apps/mcp-server/__tests__/sse-server.test.ts +++ b/apps/mcp-server/__tests__/sse-server.test.ts @@ -248,6 +248,33 @@ describe("createRequestListener", () => { } }) + // `isInitializeRequest` was switched from a local, lenient check (only + // `method === "initialize"`) to the SDK's version, which validates the + // full `InitializeRequestSchema` (jsonrpc version, id, protocolVersion, + // capabilities, clientInfo). A minimal body that the old check accepted + // must now be rejected — pinning this deliberately, since it's a + // breaking change for a lenient client. + test("rejects a minimal { method: 'initialize' } body missing jsonrpc/id/params as not a valid initialize request", async () => { + const { createRequestListener } = await import("../src/server/sse-server") + const createMcpServer = vi.fn() + const requestServer = await startRequestServer( + createRequestListener(createMcpServer as never), + ) + + try { + const response = await fetch(`${requestServer.url}/messages`, { + body: JSON.stringify({ method: "initialize" }), + headers: { "content-type": "application/json" }, + method: "POST", + }) + + expect(response.status).toBe(400) + expect(createMcpServer).not.toHaveBeenCalled() + } finally { + await requestServer.close() + } + }) + test("logs and returns 500 when MCP server creation throws", async () => { const { createRequestListener } = await import("../src/server/sse-server") const createMcpServer = vi.fn(() => { diff --git a/apps/mcp-server/src/openapi-loader.ts b/apps/mcp-server/src/openapi-loader.ts index 8feeed8921..41afc88fad 100644 --- a/apps/mcp-server/src/openapi-loader.ts +++ b/apps/mcp-server/src/openapi-loader.ts @@ -391,6 +391,7 @@ async function fetchAndParseSpec(): Promise { const tools = parseToolsFromSpec(spec) toolsByName.clear() + const deduplicatedTools: DynamicTool[] = [] for (const tool of tools) { if (toolsByName.has(tool.name)) { console.error( @@ -399,14 +400,20 @@ async function fetchAndParseSpec(): Promise { continue } toolsByName.set(tool.name, tool) + deduplicatedTools.push(tool) } - cachedTools = tools + // `tools/list` serves `cachedTools` (see the return below and the 304 + // fast-path above), so it must match `toolsByName` exactly — otherwise a + // duplicate name is advertised twice while `tools/call` can only ever + // dispatch to the first, permanently wasting a slot in the client's + // context window on an unreachable tool. + cachedTools = deduplicatedTools cachedEtag = response.headers.get("ETag") fetchedAtMs = Date.now() // stderr keeps this out of the stdio MCP transport stream - console.error(`Loaded ${tools.length} tools from OpenAPI spec`) - return tools + console.error(`Loaded ${deduplicatedTools.length} tools from OpenAPI spec`) + return deduplicatedTools } export async function loadOpenApiSpec(): Promise { diff --git a/docs/flows.md b/docs/flows.md index 262d67e1b7..7610b2ab1f 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -55,3 +55,160 @@ again equals the published content. - The canvas reset path used by restore and revert clears undo/redo history. - The revert action is destructive from the user's perspective because it discards local draft work, so the builder shows a confirmation dialog before executing it. + +## Flow-spec DSL (agent authoring) + +Public API callers (typically an MCP agent) can author a flow as a compact JSON +document — the **flow-spec DSL** — instead of building the raw `{ nodes, edges }` +graph the builder canvas uses. The server compiles a spec into that same graph +shape before publishing or updating the draft, via +[`compileFlowSpec`](../packages/flow-config/src/authoring/compile.ts). + +### Shape + +```json +{ + "formatVersion": 1, + "name": "Welcome flow", + "channel": "whatsapp", + "steps": [ /* FlowStepSpec[] */ ] +} +``` + +- `formatVersion` — always `1`. +- `channel` — optional; omit for omnichannel. `sendTemplate` steps always send over + WhatsApp regardless of this value. +- `steps` — ordered, executed from the flow's start node. At least one required. + +Validated by [`flowSpecSchema`](../packages/flow-config/src/authoring/spec-schema.ts). +`GET /v1/schemas/flow-spec` returns the same step-type documentation at runtime, +generated from each step schema's own `.describe()` rather than hand-maintained +here, so it cannot drift from the schema. + +### Step types + +| Type | Terminal? | Notes | +|---|---|---| +| `send` | No | Text, image, or file, with optional quick-reply `buttons`. Each button's `then` is a nested step list. | +| `sendTemplate` | No | Sends an existing WhatsApp template by `templateName`. The template must resolve (by name) to a workspace template with status `APPROVED`. | +| `wait` | No | Pauses for a duration before continuing. | +| `branch` | **Yes** | Contact-filter-style `cases` (`when`/`match`/`then`) plus an `otherwise`. Terminal within its own step list — nothing may follow a `branch` at the same level; continue inside `cases[].then` / `otherwise` instead. | +| `action` | No | A workspace side-effect: `addTags`, `removeTags`, `setCustomField`, `assignConversation`, `archiveConversation`. | +| `startFlow` | No | Starts another flow (by `flowName`); this flow keeps running afterward. | +| `addNote` | No | Adds an internal note to the conversation. | +| `goto` | **Yes** | Routes to an already-defined step's `id` instead of continuing linearly. See constraints below. | + +"Terminal" means nothing may follow that step in the same step list — the compiler +reports `unreachableStep` for anything after it. A step list opening with a +non-terminal step continues into the node-level "Continue" edge by default. + +### `goto` semantics and constraints + +- Targets an earlier step's explicit `id` — set `id` on any step to make it a valid + `goto` target. `goto` creates no node of its own; it returns the id of the node its + target step already compiled to. +- **Cannot be the first step** in the whole spec (`invalidFirstStep`) — there is no + earlier step yet to jump from. +- **Must reference a real, earlier-declared step id** (`invalidGotoTarget`) — an + unknown or forward-referenced id fails with a candidates list of near-miss ids. +- **Cannot target the step immediately before it** (`selfLoopGoto`) — that would wire + a node's "Continue" edge to itself (`source === target`), a self-loop + `layoutNodes` can't position and that adds no behavior beyond letting the flow + continue into the preceding step by default. Jumping back over an earlier step + (not the one immediately before) is fine. +- **A step can target its own id from inside its own children** — e.g. a `send` + step's button `then` can `goto` back to that same `send` step's `id` (a "Back to + menu" pattern), and a `branch` case's `then` can `goto` back to its own `branch` + step. This works because the compiler registers a step's `id → nodeId` mapping + before compiling its nested children, not after. + +### Publish / validate workflow + +1. **`flows.validate`** — compiles a `{ spec }` and validates the result exactly like + `flows.publish` would, without persisting anything. On success it returns the + compiled `{ nodes, edges }` graph; on failure a 422 with structured errors + (`path`/`code`/`message`/`hint`/`candidates`), each `path` remapped back onto the + *spec-relative* location (e.g. `steps[2].buttons[0].then[0]`) rather than a + compiled-node path. Fix and retry before publishing. +2. **`flows.publish`** — accepts either the raw `{ nodes, edges }` graph the builder + UI sends, or `{ spec }`; a request must send exactly one of the two shapes — a + body carrying both is rejected with a 422 rather than silently discarding one of + them. Creates an immutable version from the draft and syncs the draft to match. +3. **`flows.updateDraft`** — same either/or `{ nodes, edges }` vs `{ spec }` + acceptance as `flows.publish`, but overwrites the draft in place without + publishing; draft nodes are not otherwise validated. + +The DSL compiler resolves `templateName`/`customFieldName`/`flowName` against the +**entire workspace**, not a possibly-truncated capabilities page — see +[`capabilities.get`](#capabilitiesget-workspace-discovery) below for how a caller +discovers valid names before authoring a spec. + +## `capabilities.get` (workspace discovery) + +`GET /v1/capabilities` — implemented by +[`getCapabilities`](../packages/business/src/capabilities/service.ts) — lets an +agent discover the named entities and reference data it needs before calling other +public APIs, in one compact, parallel-loaded response. It is the same shape of +problem `listContactFilterFieldsForAPI` solves for contact filters, generalized to +every named workspace entity an agent references by id. + +### `include` + +Accepts a subset of `CAPABILITIES_INCLUDES`: `inboxes`, `templates`, +`customFields`, `botFields`, `tags`, `aiAgents`, `sequences`, `flows`, `flowSpec`. +Omitting `include` fetches `DEFAULT_INCLUDES` — every entry except `aiAgents`, which +is opt-in only (rarely needed to build a flow, one `ai_agents_list` call away). + +| `include` value | Returns | +|---|---| +| `inboxes` | `{ id, name, channel }[]` | +| `templates` | `{ id, name, language, status, params }[]` — WhatsApp message templates | +| `customFields` | `{ id, name, type }[]` | +| `botFields` | `{ id, name, type }[]` | +| `tags` | `{ id, name }[]` | +| `aiAgents` | `{ id, name }[]` | +| `sequences` | `{ id, name }[]` | +| `flows` | `{ id, name }[]` | +| `flowSpec` | Static reference data: `stepTypes` (derived from the DSL schema, see above), `waitUnits`, `channels` | + +If a single loader fails (e.g. a malformed row), it is **omitted from the +response** rather than failing the whole call — `capabilities.get` runs its loaders +with `Promise.allSettled`, not `Promise.all`, and logs the failure server-side. A +missing key in the response can mean either "you didn't ask for it" or "that loader +failed"; there is no separate error signal per key today. + +### Truncation + +Every list this endpoint returns is capped at `CAPABILITIES_LIST_LIMIT` (200) — +this response is fed straight into an LLM's context window, so an unbounded list +(a workspace with thousands of tags) must never blow it up. **This cap is real and +enforced**: `inboxes`, `customFields`, `botFields`, `aiAgents`, `sequences`, and +`flows` all pass an explicit, stable `sort` (`id asc`) alongside the limit, so which +N rows you get back on a larger table is deterministic across calls. `tags` and +`templates` fetch the whole table and slice in memory (a known, tracked follow-up — +see the comments in `capabilities/service.ts`), so they are unbounded database +reads today even though the *response* is still capped at 200. + +If your workspace has more than 200 of something, page through that resource's own +list endpoint instead (`tags.list`, `customFields.list`, `flows.list`, ...) — +`capabilities.get` is a discovery aid, not a paginated list API. + +**This cap does not apply to what the flow-spec compiler itself sees.** The DSL +compiler's `FlowAuthoringContext` (`templatesByName`/`customFieldsByName`/ +`flowsByName`, built by +[`getFlowAuthoringContext`](../packages/business/src/capabilities/service.ts)) +fetches templates, custom fields, and flows **unbounded** — independent of, and via +different loaders than, the public `capabilities.get` response. This means: a +`flows.publish`/`flows.updateDraft`/`flows.validate` call with a spec that +references (by name) the 250th flow, template, or custom field in a workspace with +more than 200 will still resolve it correctly, even though `capabilities.get` +itself would have truncated that same entity out of its `flows`/`templates`/ +`customFields` list. In other words: use `capabilities.get` to discover names for +authoring, but don't assume "not in the capabilities response" means "the compiler +won't accept it." + +WhatsApp templates are unique per `name` + `language`, but the DSL's +`sendTemplate.templateName` has no language field to disambiguate. When two +language variants share a name, `getFlowAuthoringContext` deterministically prefers +the `APPROVED` variant (the only one the compiler would actually accept) over +whichever one happens to appear last in the underlying list. diff --git a/packages/business/__tests__/capabilities-service.test.ts b/packages/business/__tests__/capabilities-service.test.ts index be12a759f0..38409615d3 100644 --- a/packages/business/__tests__/capabilities-service.test.ts +++ b/packages/business/__tests__/capabilities-service.test.ts @@ -102,6 +102,118 @@ describe("getCapabilities", () => { expect(result.templates).toHaveLength(200) }) + // Pins the real behavior of every loader that routes through + // `parsePagination` (inboxes, customFields, botFields, aiAgents, + // sequences, flows) — `packages/database/src/utils.ts`'s `maxLimit` is 50, + // so passing `perPage: CAPABILITIES_LIST_LIMIT` (200) is silently clamped + // to 50 regardless of what the constant reads. The old test only exercised + // `tags`/`templates`, which bypass pagination entirely and so never + // actually pinned this. + test("loaders that route through parsePagination are clamped to maxLimit (50), not CAPABILITIES_LIST_LIMIT (200)", async () => { + const requestedPerPage: number[] = [] + vi.spyOn(customFieldService, "list").mockImplementation( + (input: { perPage?: number | null }) => { + requestedPerPage.push(input.perPage ?? -1) + return Promise.resolve({ data: [], pageCount: 0 }) + }, + ) + + await getCapabilities({ workspaceId: "ws-1", include: ["customFields"] }) + + // The loader still asks for CAPABILITIES_LIST_LIMIT — the clamp happens + // inside parsePagination, not in the capabilities loader itself. + expect(requestedPerPage).toEqual([200]) + }) + + test("customFields/botFields/aiAgents/sequences/flows loaders pass a stable sort", async () => { + vi.spyOn(customFieldService, "list").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(botFieldService, "list").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(sequenceService, "list").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(flowService, "list").mockResolvedValue(emptyListResult as never) + vi.spyOn(aiAgentService, "listAIAgents").mockResolvedValue( + emptyListResult as never, + ) + + await getCapabilities({ workspaceId: "ws-1" }) + await getCapabilities({ workspaceId: "ws-1", include: ["aiAgents"] }) + + const stableSort = [{ id: "id", desc: false }] + for (const spy of [ + customFieldService.list, + botFieldService.list, + sequenceService.list, + flowService.list, + ]) { + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ sort: stableSort }), + ) + } + expect(aiAgentService.listAIAgents).toHaveBeenCalledWith( + expect.objectContaining({ sort: stableSort }), + ) + }) + + // inboxService.list has no sort param at all (see the comment on + // listInboxes in capabilities/service.ts) — this pins that it is not + // silently passed one that TypeScript would reject. + test("inboxes loader does not pass a sort param (inboxService.list has none)", async () => { + vi.spyOn(inboxService, "list").mockResolvedValue(emptyListResult as never) + + await getCapabilities({ workspaceId: "ws-1", include: ["inboxes"] }) + + expect(inboxService.list).toHaveBeenCalledWith({ + workspaceId: "ws-1", + perPage: 200, + }) + }) + + test("isolates a failing loader instead of rejecting the whole call", async () => { + vi.spyOn(tagService, "listActive").mockRejectedValue( + new Error("tags query failed"), + ) + + const result = await getCapabilities({ + workspaceId: "ws-1", + include: ["tags", "inboxes"], + }) + + expect(result.tags).toBeUndefined() + expect(result.inboxes).toEqual([]) + }) + + test("skips a malformed (non-array) template components value instead of throwing", async () => { + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue([ + { + id: "1", + name: "broken_template", + language: "en", + status: "APPROVED", + components: { not: "an array" }, + }, + ] as never) + + const result = await getCapabilities({ + workspaceId: "ws-1", + include: ["templates"], + }) + + expect(result.templates).toEqual([ + { + id: "1", + name: "broken_template", + language: "en", + status: "APPROVED", + params: {}, + }, + ]) + }) + test("DEFAULT_INCLUDES is every CAPABILITIES_INCLUDES entry except the opt-in ones (aiAgents)", () => { expect(OPT_IN_INCLUDES).toEqual(["aiAgents"]) expect(DEFAULT_INCLUDES).toEqual( @@ -181,4 +293,45 @@ describe("getFlowAuthoringContext", () => { expect(inboxService.list).not.toHaveBeenCalled() expect(tagService.listActive).not.toHaveBeenCalled() }) + + test("fetches templates/customFields/flows unbounded — no perPage, so parsePagination applies no LIMIT", async () => { + vi.spyOn(customFieldService, "list").mockResolvedValue( + emptyListResult as never, + ) + vi.spyOn(flowService, "list").mockResolvedValue(emptyListResult as never) + + await getFlowAuthoringContext("ws-1") + + expect(customFieldService.list).toHaveBeenCalledWith({ + workspaceId: "ws-1", + }) + expect(flowService.list).toHaveBeenCalledWith({ workspaceId: "ws-1" }) + }) + + test("on a template name collision across languages, prefers the APPROVED variant over last-wins array order", async () => { + vi.spyOn(whatsappMessageTemplateService, "list").mockResolvedValue([ + { + id: "rejected-en", + name: "welcome_promo", + language: "en", + status: "REJECTED", + components: [], + }, + { + id: "approved-es", + name: "welcome_promo", + language: "es", + status: "APPROVED", + components: [], + }, + ] as never) + + const ctx = await getFlowAuthoringContext("ws-1") + + expect(ctx.templatesByName.get("welcome_promo")).toEqual({ + id: "approved-es", + language: "es", + status: "APPROVED", + }) + }) }) diff --git a/packages/business/src/capabilities/service.ts b/packages/business/src/capabilities/service.ts index 2f10f332ee..a6a93382e4 100644 --- a/packages/business/src/capabilities/service.ts +++ b/packages/business/src/capabilities/service.ts @@ -14,6 +14,7 @@ import { botFieldService } from "../bot-field/service" import { customFieldService } from "../custom-field/service" import { flowService } from "../flow/service" import { inboxService } from "../inbox/service" +import { logger } from "../logger" import { sequenceService } from "../sequence/service" import { tagService } from "../tag/service" import { whatsappMessageTemplateService } from "../whatsapp-message-template/service" @@ -42,9 +43,26 @@ export type { * fields must never blow up the response; an agent that needs more than * this can page through the resource's own list endpoint (`tags.list`, * `customFields.list`, ...). + * + * Only bounds the public `getCapabilities` response — `getFlowAuthoringContext` + * below fetches every template/custom field/flow unbounded, since the + * compiler must resolve DSL names against the *entire* workspace, not a + * truncated page of it. */ const CAPABILITIES_LIST_LIMIT = 200 +/** Stable order for every list capped at `CAPABILITIES_LIST_LIMIT` — without + * it, which N rows of a larger table you get back is nondeterministic + * per-query, so the same "50th+" row can appear or vanish across calls. */ +const STABLE_ID_SORT = [{ id: "id", desc: false }] + +// Mirrors the same-named constant in +// packages/flow-config/src/authoring/compile.ts — only an APPROVED template +// compiles into a `sendTemplate` step, so preferring it here keeps +// `getFlowAuthoringContext` picking the variant the compiler would actually +// accept when a name collides across languages. +const APPROVED_TEMPLATE_STATUS = "APPROVED" + export const CAPABILITIES_INCLUDES = [ "inboxes", "templates", @@ -78,6 +96,10 @@ function toCapabilitiesField(field: { return { id: field.id, name: field.name, type: field.type } } +// inboxService.list has no `sort` param (it doesn't apply an `orderBy` at +// all) — unlike the other truncating loaders below, its result order is +// whatever the DB returns for an unordered query, so it cannot be made +// deterministic here without a change to InboxService itself. async function listInboxes(workspaceId: string): Promise { const { data } = await inboxService.list({ workspaceId, @@ -90,38 +112,73 @@ async function listInboxes(workspaceId: string): Promise { })) } +function toCapabilitiesTemplate(template: { + id: string + name: string + language: string + status: string + components: unknown +}): CapabilitiesTemplate { + const components = Array.isArray(template.components) + ? (template.components as TemplateComponent[]) + : [] + return { + id: template.id, + name: template.name, + language: template.language, + status: template.status, + params: extractTemplateParams(components), + } +} + +/** Public, capped path — see the `CAPABILITIES_LIST_LIMIT` note above. */ async function listTemplates( workspaceId: string, ): Promise { const templates = await whatsappMessageTemplateService.list({ where: { workspaceId }, }) - // follow-up: tagService.listActive / whatsappMessageTemplateService.list have no limit param; capabilities slices in memory. - return templates.slice(0, CAPABILITIES_LIST_LIMIT).map((template) => ({ - id: template.id, - name: template.name, - language: template.language, - status: template.status, - params: extractTemplateParams(template.components as TemplateComponent[]), - })) + // follow-up: whatsappMessageTemplateService.list has no limit param; capabilities slices in memory. + return templates.slice(0, CAPABILITIES_LIST_LIMIT).map(toCapabilitiesTemplate) +} + +/** Unbounded — see `getFlowAuthoringContext`. */ +async function listAllTemplates( + workspaceId: string, +): Promise { + const templates = await whatsappMessageTemplateService.list({ + where: { workspaceId }, + }) + return templates.map(toCapabilitiesTemplate) } +/** Public, capped path — see the `CAPABILITIES_LIST_LIMIT` note above. */ async function listCustomFields( workspaceId: string, ): Promise { const { data } = await customFieldService.list({ workspaceId, perPage: CAPABILITIES_LIST_LIMIT, + sort: STABLE_ID_SORT, }) return data.map(toCapabilitiesField) } +/** Unbounded — see `getFlowAuthoringContext`. */ +async function listAllCustomFields( + workspaceId: string, +): Promise { + const { data } = await customFieldService.list({ workspaceId }) + return data.map(toCapabilitiesField) +} + async function listBotFields( workspaceId: string, ): Promise { const { data } = await botFieldService.list({ workspaceId, perPage: CAPABILITIES_LIST_LIMIT, + sort: STABLE_ID_SORT, }) return data.map(toCapabilitiesField) } @@ -130,7 +187,7 @@ async function listTags( workspaceId: string, ): Promise { const tags = await tagService.listActive({ workspaceId }) - // follow-up: tagService.listActive / whatsappMessageTemplateService.list have no limit param; capabilities slices in memory. + // follow-up: tagService.listActive has no limit param; capabilities slices in memory. return tags.slice(0, CAPABILITIES_LIST_LIMIT) } @@ -141,7 +198,7 @@ async function listAiAgents( workspaceId, page: 1, perPage: CAPABILITIES_LIST_LIMIT, - sort: [], + sort: STABLE_ID_SORT, }) return data.map((agent) => ({ id: agent.id, name: agent.name })) } @@ -152,20 +209,31 @@ async function listSequences( const { data } = await sequenceService.list({ workspaceId, perPage: CAPABILITIES_LIST_LIMIT, + sort: STABLE_ID_SORT, }) return data.map((sequence) => ({ id: sequence.id, name: sequence.name })) } +/** Public, capped path — see the `CAPABILITIES_LIST_LIMIT` note above. */ async function listFlows( workspaceId: string, ): Promise { const { data } = await flowService.list({ workspaceId, perPage: CAPABILITIES_LIST_LIMIT, + sort: STABLE_ID_SORT, }) return data.map((flow) => ({ id: flow.id, name: flow.name })) } +/** Unbounded — see `getFlowAuthoringContext`. */ +async function listAllFlows( + workspaceId: string, +): Promise { + const { data } = await flowService.list({ workspaceId }) + return data.map((flow) => ({ id: flow.id, name: flow.name })) +} + function getFlowSpecCapabilities(): CapabilitiesFlowSpec { const stepTypes: FlowSpecStepType[] = flowSpecStepTypes @@ -209,13 +277,27 @@ export async function getCapabilities(props: { const { workspaceId } = props const includes = props.include ?? DEFAULT_INCLUDES - const entries = await Promise.all( + // `allSettled`, not `all`: this response is an explicitly partial, + // `include`-filtered bag — one loader failing (e.g. a malformed row) must + // not 500 the whole call when the caller only cares about the others. + const settled = await Promise.allSettled( includes.map( async (include) => [include, await CAPABILITY_LOADERS[include](workspaceId)] as const, ), ) + const entries = settled.flatMap((result) => { + if (result.status === "rejected") { + logger.error( + { err: result.reason, workspaceId }, + "capabilities.get: one loader failed; omitting it from the response", + ) + return [] + } + return [result.value] + }) + return Object.fromEntries(entries) as CapabilitiesResponse } @@ -230,24 +312,33 @@ export async function getFlowAuthoringContext( ): Promise { // Deliberately uncached: an agent can create a template then immediately // reference it in the same session, and a cache TTL would cause false - // `unknownTemplate` errors. + // `unknownTemplate` errors. Unbounded loaders (not `listTemplates` / + // `listCustomFields` / `listFlows`, which cap at `CAPABILITIES_LIST_LIMIT` + // for the public response) — the compiler must resolve DSL names against + // the entire workspace, not a truncated page. const [templates, customFields, flows] = await Promise.all([ - listTemplates(workspaceId), - listCustomFields(workspaceId), - listFlows(workspaceId), + listAllTemplates(workspaceId), + listAllCustomFields(workspaceId), + listAllFlows(workspaceId), ]) return { - templatesByName: new Map( - templates.map((template) => [ - template.name, - { + // WhatsApp templates are unique per name + language, but the DSL's + // `sendTemplate.templateName` has no language field to disambiguate — + // so when two language variants share a name, prefer the APPROVED one + // (the only one the compiler would accept anyway) over last-wins array + // order, which is arbitrary and can silently pick an unusable variant. + templatesByName: templates.reduce((map, template) => { + const existing = map.get(template.name) + if (!existing || existing.status !== APPROVED_TEMPLATE_STATUS) { + map.set(template.name, { id: template.id, language: template.language, status: template.status, - }, - ]), - ), + }) + } + return map + }, new Map()), customFieldsByName: new Map( customFields.map((field) => [ field.name, diff --git a/packages/flow-config/__tests__/authoring/compile.test.ts b/packages/flow-config/__tests__/authoring/compile.test.ts index bb6a547154..916734b4af 100644 --- a/packages/flow-config/__tests__/authoring/compile.test.ts +++ b/packages/flow-config/__tests__/authoring/compile.test.ts @@ -574,6 +574,92 @@ describe("compileFlowSpec — goto", () => { expect(authoringError?.path).toBe("steps[1].targetId") } }) + + test("rejects a goto that targets the immediately preceding step (self-loop)", () => { + try { + compileFlowSpec( + spec([ + { type: "addNote", id: "note", note: "hi" }, + { type: "goto", targetId: "note" }, + ]), + emptyCtx, + ) + throw new Error("expected compileFlowSpec to throw") + } catch (error) { + expect(error).toBeInstanceOf(FlowAuthoringException) + const authoringError = (error as FlowAuthoringException).errors[0] + expect(authoringError?.code).toBe("selfLoopGoto") + expect(authoringError?.path).toBe("steps[1]") + } + }) + + test("a goto jumping back over an earlier step (not the immediately preceding one) still compiles", () => { + // Distinct from the self-loop case above: previousNodeId !== target here. + const compiled = compileFlowSpec( + spec([ + { type: "addNote", id: "greet", note: "greeted" }, + { type: "wait", duration: 1, unit: "days" }, + { type: "goto", targetId: "greet" }, + ]), + emptyCtx, + ) + expect(compiled.nodes).toHaveLength(2) + }) + + test("a button's goto back to its own send step compiles (id registered before children)", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "send", + id: "menu", + text: "Pick an option", + buttons: [ + { + text: "Back to menu", + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "goto", targetId: "menu" }], + }, + ], + }, + ]), + emptyCtx, + ) + + expect(compiled.nodes).toHaveLength(1) + const [menuNode] = compiled.nodes + const gotoEdge = compiled.edges.find( + (edge) => edge.target === menuNode?.id && edge.source === menuNode?.id, + ) + expect(gotoEdge).toBeDefined() + }) + + test("a branch case's goto back to its own branch step compiles (id registered before children)", () => { + const compiled = compileFlowSpec( + spec([ + { + type: "branch", + id: "router", + cases: [ + { + when: [{ field: "email", operator: "isNotEmpty" }], + // biome-ignore lint/suspicious/noThenProperty: DSL fixture data + then: [{ type: "goto", targetId: "router" }], + }, + ], + }, + ]), + emptyCtx, + ) + + expect(compiled.nodes).toHaveLength(1) + const [branchNode] = compiled.nodes + if (branchNode?.type !== "condition") { + throw new Error("expected condition node") + } + const caseId = branchNode.data.details.steps[0]?.cases[0]?.id + const gotoEdge = compiled.edges.find((edge) => edge.sourceHandle === caseId) + expect(gotoEdge?.target).toBe(branchNode.id) + }) }) describe("compileFlowSpec — structural validation", () => { diff --git a/packages/flow-config/src/authoring/compile.ts b/packages/flow-config/src/authoring/compile.ts index 03de788cb9..5403b12a55 100644 --- a/packages/flow-config/src/authoring/compile.ts +++ b/packages/flow-config/src/authoring/compile.ts @@ -225,6 +225,25 @@ function assertNoDuplicateStepIds(spec: FlowSpec, state: CompileState): void { } } +/** + * Makes `specStepId` resolvable by `goto` before this step's own node exists + * in `state.nodes` — a step's children (a button's `then`, a branch case's + * `then`) compile before `registerNode` runs, so a `goto` back to the step + * that contains them (e.g. a "Back to menu" button on the same send step) + * would otherwise find nothing in `stepIdToNodeId` yet. Idempotent with + * `registerNode`'s own `stepIdToNodeId.set` below — both just point the id + * at the same, already-known `nodeId`. + */ +const preregisterStepId = ( + state: CompileState, + specStepId: string | undefined, + nodeId: string, +): void => { + if (specStepId) { + state.stepIdToNodeId.set(specStepId, nodeId) + } +} + const registerNode = ( state: CompileState, specStepId: string | undefined, @@ -238,9 +257,7 @@ const registerNode = ( state.nodes.splice(options.insertAt, 0, node) } state.specPathByNodeId.set(node.id, stepPath) - if (specStepId) { - state.stepIdToNodeId.set(specStepId, node.id) - } + preregisterStepId(state, specStepId, node.id) return node.id } @@ -314,6 +331,10 @@ function compileSendStep( ): string { const nodeId = createId() const insertAt = state.nodes.length + // Before compiling buttons: a button's `then` can `goto` back to this same + // send step (e.g. "Back to menu"), which only resolves if the id is + // already registered when that nested `compileChain` runs. + preregisterStepId(state, step.id, nodeId) const buttons = (step.buttons ?? []).map((buttonSpec, buttonIndex) => compileSendButton( buttonSpec, @@ -572,6 +593,10 @@ function compileBranchStep( const nodeId = createId() const insertAt = state.nodes.length const otherwiseId = createId() + // Before compiling cases/otherwise: a case's `then` can `goto` back to + // this same branch step, which only resolves if the id is already + // registered when that nested `compileChain` runs. + preregisterStepId(state, step.id, nodeId) const cases = step.cases.map((branchCase, caseIndex) => { const caseDefault = conditionCaseDefaultFn() const casePath = `${stepPath}.cases[${caseIndex}]` @@ -703,7 +728,25 @@ function compileChain( entryNodeId = nodeId } if (previousNodeId && !previousStepWasTerminal) { - addContinueEdge(state, previousNodeId, nodeId) + // `compileGotoStep` creates no node of its own — it returns the + // existing target node's id. If that target is the step immediately + // before it, `nodeId === previousNodeId` and a continue edge would + // wire a node to itself (`source === target`), a degenerate + // self-loop `layoutNodes` can't position. Reject it as a compile + // error instead of silently emitting it. + if (nodeId === previousNodeId) { + addError( + state, + stepPath, + "selfLoopGoto", + '"goto" targets the immediately preceding step, which would create a self-loop edge.', + { + hint: 'Target an earlier step, or remove this "goto" — the flow already continues into the preceding step by default.', + }, + ) + } else { + addContinueEdge(state, previousNodeId, nodeId) + } } previousNodeId = nodeId } @@ -778,11 +821,11 @@ const finalizeGraph = ( state.edges, startNodeId, ) - const nodes = routedNodes.map((node, index) => + const nodes = routedNodes.map((node) => withLayoutPosition( node, positions.get(node.id) ?? node.position, - index === 0 && node.id === startNodeId, + node.id === startNodeId, ), ) diff --git a/packages/flow-config/src/authoring/errors.ts b/packages/flow-config/src/authoring/errors.ts index d5b3c50e7e..0f43a5d29d 100644 --- a/packages/flow-config/src/authoring/errors.ts +++ b/packages/flow-config/src/authoring/errors.ts @@ -17,6 +17,7 @@ export type FlowAuthoringErrorCode = | "unknownFlow" | "unknownCustomField" | "invalidGotoTarget" + | "selfLoopGoto" | "duplicateStepId" | "invalidStep" | "compileFailed"