From ee8cbff9d4c0a76f82b25c5234756710ea746e3e Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Thu, 21 May 2026 18:57:47 -0600 Subject: [PATCH] feat(commcare): inbound API list + create atoms; conditional-alert deferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commcare_list_inbound_apis: POST paginate to /motech/inbound/. - commcare_create_inbound_api: action=create on the same URL with the ConfigurableAPICreateForm fields (name, description, filter_expression, transform_expression, backend). Returns the new id either from the CRUD response itemData or via list-and-find. Both gated by Pro/DATA_FORWARDING. Verified against /tmp/ace-refs/hq/corehq/motech/generic_inbound/views.py + forms.py. V1 scope cut: conditional alerts and custom-user-data-fields creation are deferred from V1's atom matrix. - Conditional alert create is a 3-form combined POST (basic_info + criteria + schedule) with dynamic fields and async handlers — multi-day build, low ROI for V1 since each cohort needs only one alert. Bulk upload is XLS, not CSV (probe report was wrong). - User Fields create is a hidden-JSON form. Same conclusion: one-time-per-domain, doable manually for V1. The /ace:interview-domain-bootstrap skill will prompt the operator to do these two manual steps and then continue. Atoms can land in V1.5. Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- mcp/connect-server.ts | 19 +++++ mcp/connect/backends/commcare.ts | 136 +++++++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d3c4e021..9294b0f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ "url": "https://github.com/jjackson" }, "metadata": { - "version": "0.13.320" + "version": "0.13.321" }, "plugins": [ { "name": "ace", "source": "./", - "version": "0.13.320", + "version": "0.13.321", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b89b0935..26fc1bc4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.320", + "version": "0.13.321", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout", "author": { "name": "Jonathan Jackson", diff --git a/VERSION b/VERSION index 5fe0e082..c86f8093 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.320 +0.13.321 diff --git a/mcp/connect-server.ts b/mcp/connect-server.ts index e292168c..9aa7e1d6 100644 --- a/mcp/connect-server.ts +++ b/mcp/connect-server.ts @@ -506,6 +506,25 @@ server.tool('commcare_create_lookup_table', async (args) => runAtom(async () => (await commcareClient()).createLookupTable(args)) ); +server.tool('commcare_list_inbound_apis', + 'List Inbound API configurations on a CommCare HQ domain. POST /a//motech/inbound/ with action=paginate. Returns each API\'s id, name, description, api_url, edit_url. Pro Edition / DATA_FORWARDING required.', + { domain: z.string(), limit: z.number().int().positive().optional() }, + async (args) => runAtom(async () => (await commcareClient()).listInboundApis(args)) +); + +server.tool('commcare_create_inbound_api', + 'Create an Inbound API configuration. POST the ConfigurableAPICreateForm to /a//motech/inbound/ via CRUDPaginatedViewMixin\'s action=create. Requires filter_expression_id (UCR FK) and optionally transform_expression_id — these UCR expressions must exist on the domain first (typically pushed via linked_domain in the Connect Interviews flow). Returns new id and name. The Connect Interviews "Session Completion API" + "24 hr Expiry API" are created via this atom in the per-domain bootstrap.', + { + domain: z.string(), + name: z.string(), + description: z.string().optional(), + filter_expression_id: z.number().int().positive(), + transform_expression_id: z.number().int().positive().optional(), + backend: z.enum(['json', 'form_data']).optional(), + }, + async (args) => runAtom(async () => (await commcareClient()).createInboundApi(args)) +); + server.tool('commcare_create_repeater', 'Create a Data-Forwarding Repeater on a CommCare HQ domain. POST the GenericRepeaterForm (or BaseExpressionRepeaterForm for *ExpressionRepeater types) to /a//motech/forwarding/new//. Plain FormRepeater forwards every submission; FormExpressionRepeater applies a UCR filter (configured_filter) and emits a UCR-derived payload (configured_expression) — the Connect Interviews "OCS User Registration" and "Trigger Bot" repeaters use this variant. Pro Edition required (DATA_FORWARDING privilege).', { diff --git a/mcp/connect/backends/commcare.ts b/mcp/connect/backends/commcare.ts index e18144e2..8c4ad2f0 100644 --- a/mcp/connect/backends/commcare.ts +++ b/mcp/connect/backends/commcare.ts @@ -178,6 +178,31 @@ export interface Repeater { repeater_type: string; } +export interface InboundApi { + id: number; + name: string; + description: string; + api_url: string; + edit_url: string; +} + +export interface ListInboundApisArgs { + domain: string; + limit?: number; +} + +export interface CreateInboundApiArgs { + domain: string; + name: string; + description?: string; + /** UCR expression FK (integer id from a UCRExpression that's been pushed via linked_domain). */ + filter_expression_id: number; + /** Optional transform-expression FK. */ + transform_expression_id?: number; + /** ApiBackendOptions slug — default 'json' (the common case). */ + backend?: 'json' | 'form_data'; +} + export interface CreateConnectionArgs { domain: string; name: string; @@ -1129,6 +1154,117 @@ export class CommCareBackend { }); } + /** + * List Inbound API configurations on a domain. POST /a//motech/ + * inbound/ with `action=paginate` via the CRUDPaginatedView (same + * pattern as connections). Returns each API's id, name, description, + * api_url. + * + * Pro/DATA_FORWARDING gated. + * Verified against /tmp/ace-refs/hq/corehq/motech/generic_inbound/views.py:52-110. + */ + async listInboundApis(args: ListInboundApisArgs): Promise<{ apis: InboundApi[]; total: number }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/motech/inbound/`; + const csrf = await this.csrfFromCookies(request); + const params = new URLSearchParams({ action: 'paginate', page: '1', limit: String(args.limit ?? 100) }); + const res = await request.post(`${this.opts.baseUrl}${path}`, { + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrf ?? '', + 'X-Requested-With': 'XMLHttpRequest', + Referer: `${this.opts.baseUrl}${path}`, + }, + maxRedirects: 0, + }); + if (res.status() === 404) { + throw new Error(`commcare_list_inbound_apis: 404 — domain ${args.domain} lacks DATA_FORWARDING privilege.`); + } + if (res.status() !== 200) { + throw new Error(`commcare_list_inbound_apis POST ${path} returned ${res.status()}: ${(await res.text()).slice(0, 300)}`); + } + const body = JSON.parse(await res.text()) as { paginatedList?: Array<{ itemData?: any }>; total?: number }; + const apis = (body.paginatedList ?? []).map((row) => { + const d = row.itemData ?? {}; + return { + id: Number(d.id), + name: String(d.name ?? ''), + description: String(d.description ?? ''), + api_url: String(d.api_url ?? ''), + edit_url: String(d.edit_url ?? ''), + }; + }); + return { apis, total: body.total ?? apis.length }; + }); + } + + /** + * Create a new Inbound API. POSTs the ConfigurableAPICreateForm to + * /a//motech/inbound/ via CRUDPaginatedViewMixin's `action=create`. + * + * Requires `filter_expression` and (optionally) `transform_expression` + * to refer to existing UCRExpression FKs on the domain — these are + * pushed via linked_domain from the master domain in the + * Connect Interviews flow. + * + * Returns the new API's id (re-lists by name). + * + * Verified against /tmp/ace-refs/hq/corehq/motech/generic_inbound/ + * forms.py:17-50 + views.py:52-110. + */ + async createInboundApi(args: CreateInboundApiArgs): Promise<{ id: number; name: string }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/motech/inbound/`; + const csrf = await this.csrfFromCookies(request); + const params = new URLSearchParams(); + params.set('action', 'create'); + params.set('csrfmiddlewaretoken', csrf ?? ''); + params.set('name', args.name); + params.set('description', args.description ?? ''); + params.set('filter_expression', String(args.filter_expression_id)); + if (args.transform_expression_id) { + params.set('transform_expression', String(args.transform_expression_id)); + } + params.set('backend', args.backend ?? 'json'); + const res = await request.post(`${this.opts.baseUrl}${path}`, { + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrf ?? '', + 'X-Requested-With': 'XMLHttpRequest', + Referer: `${this.opts.baseUrl}${path}`, + }, + maxRedirects: 0, + }); + if (res.status() !== 200) { + throw new Error( + `commcare_create_inbound_api POST ${path} returned ${res.status()}: ${(await res.text()).slice(0, 400)}`, + ); + } + // CRUDPaginatedView returns JSON with itemData containing id + const body = JSON.parse(await res.text()) as { itemData?: any; form?: any; errors?: any }; + if (body.itemData?.id) { + return { id: Number(body.itemData.id), name: String(body.itemData.name ?? args.name) }; + } + // Validation error case + if (body.errors || body.form) { + throw new Error( + `commcare_create_inbound_api: form validation failed. Server response: ${JSON.stringify(body).slice(0, 400)}`, + ); + } + // Fall back to list-and-find + const list = await this.listInboundApis({ domain: args.domain }); + const match = list.apis.find((a) => a.name === args.name); + if (!match) { + throw new Error( + `commcare_create_inbound_api: created but could not find "${args.name}" by name. Names visible: ${list.apis.map((a) => a.name).join(', ')}`, + ); + } + return { id: match.id, name: match.name }; + }); + } + /** * Create a Data-Forwarding Repeater on a domain. POST the GenericRepeaterForm * (or BaseExpressionRepeaterForm for *ExpressionRepeater types) to diff --git a/package.json b/package.json index 8018d626..f4ac871d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.320", + "version": "0.13.321", "description": "AI Connect Engine - orchestrator for building Connect Opps using AI", "type": "module", "scripts": {