diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 314a56b7..185dbea5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ "url": "https://github.com/jjackson" }, "metadata": { - "version": "0.13.324" + "version": "0.13.325" }, "plugins": [ { "name": "ace", "source": "./", - "version": "0.13.324", + "version": "0.13.325", "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 780759bc..7aa19f0d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.324", + "version": "0.13.325", "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 2d7803d8..4a0eebb7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.324 +0.13.325 diff --git a/mcp/connect-server.ts b/mcp/connect-server.ts index 9aa7e1d6..3cd62dbc 100644 --- a/mcp/connect-server.ts +++ b/mcp/connect-server.ts @@ -506,6 +506,24 @@ server.tool('commcare_create_lookup_table', async (args) => runAtom(async () => (await commcareClient()).createLookupTable(args)) ); +server.tool('commcare_list_ucr_expressions', + 'List named UCR expressions / filters on a CommCare HQ domain. POST /a//data/ucr_expressions/ with action=paginate via CRUDPaginatedView. Returns id, name, expression_type ("named_expression" | "named_filter"), description, parsed definition JSON. Auth: session (BaseProjectDataView).', + { domain: z.string(), limit: z.number().int().positive().optional() }, + async (args) => runAtom(async () => (await commcareClient()).listUcrExpressions(args)) +); + +server.tool('commcare_create_ucr_expression', + 'Create a named UCR expression or filter on a domain. POST the UCRExpressionForm to /a//data/ucr_expressions/ via action=create. Required fields: name, expression_type ("named_expression" | "named_filter"), definition (JSON spec). The Connect Interviews bootstrap creates 4: "Register User OCS" + "Trigger OCS Bot" (named_filter), "Session Completion API" + "24 hr Expiry API" (named_expression). Duplicate name in domain raises IntegrityError surfaced as explicit error.', + { + domain: z.string(), + name: z.string(), + expression_type: z.enum(['named_expression', 'named_filter']), + definition: z.record(z.any()).describe('The UCR spec JSON object (e.g. {"type": "boolean_expression", ...}).'), + description: z.string().optional(), + }, + async (args) => runAtom(async () => (await commcareClient()).createUcrExpression(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() }, diff --git a/mcp/connect/backends/commcare.ts b/mcp/connect/backends/commcare.ts index 8c4ad2f0..65ceaeed 100644 --- a/mcp/connect/backends/commcare.ts +++ b/mcp/connect/backends/commcare.ts @@ -186,6 +186,31 @@ export interface InboundApi { edit_url: string; } +export type UcrExpressionType = 'named_expression' | 'named_filter'; + +export interface UcrExpression { + id: number; + name: string; + expression_type: UcrExpressionType; + description: string; + /** JSON-encoded definition (parsed). */ + definition: Record | null; +} + +export interface ListUcrExpressionsArgs { + domain: string; + limit?: number; +} + +export interface CreateUcrExpressionArgs { + domain: string; + name: string; + expression_type: UcrExpressionType; + /** The UCR spec (a JSON object — passed as JSON-encoded string to the form). */ + definition: Record; + description?: string; +} + export interface ListInboundApisArgs { domain: string; limit?: number; @@ -1154,6 +1179,128 @@ export class CommCareBackend { }); } + /** + * List named UCR expressions / filters on a domain. POST + * /a//data/ucr_expressions/ with `action=paginate` via the + * CRUDPaginatedView (same pattern as Connections + Inbound APIs). + * Returns each expression's id, name, expression_type, description, + * and parsed definition JSON. + * + * Auth: session via BaseProjectDataView (domain admin / edit_apps). + * + * Verified against + * /tmp/ace-refs/hq/corehq/apps/userreports/views.py:1882-1965 + + * /tmp/ace-refs/hq/corehq/apps/data_interfaces/urls.py:68. + */ + async listUcrExpressions(args: ListUcrExpressionsArgs): Promise<{ expressions: UcrExpression[]; total: number }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/data/ucr_expressions/`; + 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() !== 200) { + throw new Error(`commcare_list_ucr_expressions 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 expressions = (body.paginatedList ?? []).map((row) => { + const d = row.itemData ?? {}; + let parsedDef: Record | null = null; + try { + parsedDef = d.definition ? JSON.parse(d.definition) : null; + } catch { /* malformed JSON; leave null */ } + return { + id: Number(d.id), + name: String(d.name ?? ''), + expression_type: (d.type ?? 'named_expression') as UcrExpressionType, + description: String(d.description ?? ''), + definition: parsedDef, + }; + }); + return { expressions, total: body.total ?? expressions.length }; + }); + } + + /** + * Create a named UCR expression / filter on a domain. POSTs the + * UCRExpressionForm to /a//data/ucr_expressions/ via + * `action=create`. Returns the new expression's id. + * + * Required fields per + * /tmp/ace-refs/hq/corehq/apps/userreports/forms.py:11 : + * - name (CharField) + * - expression_type ("named_expression" | "named_filter") + * - definition (JSONField — serialized as JSON-encoded string) + * + * IntegrityError on duplicate name in domain surfaces as an explicit error. + */ + async createUcrExpression(args: CreateUcrExpressionArgs): Promise<{ id: number; name: string }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/data/ucr_expressions/`; + 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('expression_type', args.expression_type); + params.set('description', args.description ?? ''); + params.set('definition', JSON.stringify(args.definition)); + 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_ucr_expression POST ${path} returned ${res.status()}: ${(await res.text()).slice(0, 300)}`); + } + // CRUDPaginatedViewMixin wraps the create response as + // {newItem: {itemData: {...}, template: ...}} + // (See `paginate_crud_response` in corehq.apps.hqwebapp.views.) + const body = JSON.parse(await res.text()) as { + newItem?: { itemData?: any; error?: string }; + itemData?: any; + error?: string; + form?: any; + }; + const itemData = body.newItem?.itemData ?? body.itemData; + const errorMsg = body.newItem?.error ?? body.error; + if (errorMsg) { + // get_create_item_data returns {error: "..."} on IntegrityError + throw new Error(`commcare_create_ucr_expression: ${errorMsg}`); + } + if (itemData?.id) { + return { id: Number(itemData.id), name: String(itemData.name ?? args.name) }; + } + if (body.form) { + throw new Error( + `commcare_create_ucr_expression: form validation failed. Server response: ${JSON.stringify(body).slice(0, 400)}`, + ); + } + // Fallback: list and find + const list = await this.listUcrExpressions({ domain: args.domain }); + const match = list.expressions.find((e) => e.name === args.name); + if (!match) { + throw new Error( + `commcare_create_ucr_expression: created but could not find "${args.name}" on subsequent list. Names visible: ${list.expressions.map((e) => e.name).join(', ')}`, + ); + } + return { id: match.id, name: match.name }; + }); + } + /** * List Inbound API configurations on a domain. POST /a//motech/ * inbound/ with `action=paginate` via the CRUDPaginatedView (same @@ -1242,10 +1389,21 @@ export class CommCareBackend { `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) }; + // CRUDPaginatedViewMixin wraps the create response as + // {newItem: {itemData: {...}, template: ...}}. + const body = JSON.parse(await res.text()) as { + newItem?: { itemData?: any; error?: string }; + itemData?: any; + form?: any; + errors?: any; + }; + const itemData = body.newItem?.itemData ?? body.itemData; + const errorMsg = body.newItem?.error; + if (errorMsg) { + throw new Error(`commcare_create_inbound_api: ${errorMsg}`); + } + if (itemData?.id) { + return { id: Number(itemData.id), name: String(itemData.name ?? args.name) }; } // Validation error case if (body.errors || body.form) { diff --git a/package.json b/package.json index 6b6394a1..bf601937 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.324", + "version": "0.13.325", "description": "AI Connect Engine - orchestrator for building Connect Opps using AI", "type": "module", "scripts": {