Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.13.320
0.13.321
19 changes: 19 additions & 0 deletions mcp/connect-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>/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/<domain>/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/<domain>/motech/forwarding/new/<repeater_type>/. 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).',
{
Expand Down
136 changes: 136 additions & 0 deletions mcp/connect/backends/commcare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1129,6 +1154,117 @@ export class CommCareBackend {
});
}

/**
* List Inbound API configurations on a domain. POST /a/<domain>/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/<domain>/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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading