Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
49afaf5
feat(mcp): agent-first MCP surface, flow-spec DSL, capability discovery
realcodesiman Sep 13, 2026
78c7e90
fix(mcp): address review findings on the mcp-agent-first surface
realcodesiman Sep 14, 2026
cdd9e55
fix(mcp): curate default MCP tool set and add tool descriptions
realcodesiman Sep 15, 2026
ac261cf
refactor(flow-config): dedupe compile pipeline and cover flow-spec sc…
realcodesiman Sep 15, 2026
c613877
refactor(capabilities): derive workspace capability schema from zod
realcodesiman Sep 15, 2026
597d51c
fix(mcp): harden mcp-server request handling and dedupe flow compile …
realcodesiman Sep 15, 2026
766883e
feat(api): describe public API paging/id-param helpers and default fl…
realcodesiman Sep 15, 2026
a2181b4
feat(api): describe contacts crud/custom-fields/messages routes and s…
realcodesiman Sep 15, 2026
13214c0
feat(api): describe contacts tags routes
realcodesiman Sep 15, 2026
0edf691
feat(api): complete contacts public API description coverage (WS1 bat…
realcodesiman Sep 15, 2026
9569582
feat(api): complete flows public API description coverage
realcodesiman Sep 15, 2026
93243b6
feat(api): complete sequences public API description coverage
realcodesiman Sep 15, 2026
e8b6528
test(api): shrink description backlog for flows and sequences
realcodesiman Sep 15, 2026
9c6743a
feat(api): complete keywords public API description coverage
realcodesiman Sep 15, 2026
c59b778
feat(api): complete AI agents public API description coverage
realcodesiman Sep 15, 2026
12e0f62
feat(api): complete AI files and AI functions public API description …
realcodesiman Sep 15, 2026
c8b3404
feat(api): complete AI MCP servers and triggers public API descriptio…
realcodesiman Sep 15, 2026
6c7af5c
fix(api): correct contacts filter field reference in descriptions
realcodesiman Sep 15, 2026
fe9cdee
feat(api): complete conversations public API description coverage
realcodesiman Sep 15, 2026
52f6a50
feat(api): complete messages public API description coverage
realcodesiman Sep 15, 2026
128f887
feat(api): complete broadcasts public API description coverage
realcodesiman Sep 15, 2026
29b999d
feat(api): complete saved replies public API description coverage
realcodesiman Sep 15, 2026
3573fe4
feat(api): complete inboxes public API description coverage
realcodesiman Sep 15, 2026
bda9353
feat(api): complete inbox teams public API description coverage
realcodesiman Sep 15, 2026
c8fdddd
feat(api): complete analytics public API description coverage
realcodesiman Sep 15, 2026
0b7e669
feat(api): complete capabilities and schemas public API description c…
realcodesiman Sep 15, 2026
de1d6a3
feat(api): complete token/errorLogs/workspaceMembers/webhooks/externa…
realcodesiman Sep 15, 2026
6a0dbeb
feat(api): complete ads and ads-campaign public API description coverage
realcodesiman Sep 15, 2026
a11259b
feat(api): complete facebookLeadAds public API description coverage
realcodesiman Sep 15, 2026
93f2917
feat(api): complete media library public API description coverage
realcodesiman Sep 15, 2026
d9e01d5
feat(api): complete dynamic images public API description coverage
realcodesiman Sep 15, 2026
cc72ab4
feat(api): complete QR codes public API description coverage
realcodesiman Sep 15, 2026
1d711b4
feat(api): complete minigames public API description coverage
realcodesiman Sep 15, 2026
4a692ba
feat(api): complete coupons public API description coverage
realcodesiman Sep 15, 2026
019e21c
feat(api): complete batch 7 public API description coverage
realcodesiman Sep 15, 2026
6e132ac
feat(api): complete batch 8 public API description coverage
realcodesiman Sep 15, 2026
bf69d02
feat(api): complete batch 9 public API description coverage
realcodesiman Sep 15, 2026
9979435
fix(api,flow-config,mcp): address PR #1184 review remediation
realcodesiman Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .agents/skills/orpc-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions apps/builder/__tests__/__snapshots__/public-spec-mcp.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`default tool set > operation ids match the curated snapshot 1`] = `
[
"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.stop",
"capabilities.get",
"contacts.addTagsByName",
"contacts.create",
"contacts.get",
"contacts.list",
"contacts.listCustomFields",
"contacts.listMessages",
"contacts.listSequences",
"contacts.listTags",
"contacts.search",
"contacts.sendFlow",
"contacts.sendMessage",
"contacts.setCustomField",
"contacts.subscribeSequences",
"conversations.assign",
"conversations.get",
"conversations.list",
"errorLogs.list",
"flows.create",
"flows.get",
"flows.list",
"flows.publish",
"flows.updateDraft",
"flows.validate",
"keywords.list",
"messages.list",
"schemas.flowSpec",
"sequences.get",
"sequences.list",
"sequences.update",
"token.get",
]
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
144 changes: 144 additions & 0 deletions apps/builder/__tests__/capabilities-public-api.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading