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
6 changes: 4 additions & 2 deletions skills/formo-analytics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Useful MCP tools include:

- `list_endpoints`, `list_datasources`
- `text_to_sql`, `execute_query`, `explore_data`
- published tools such as `kpis`, `lifecycle`, `top_events`, `top_pages`, `top_sources`, `top_locations`, `revenue_overview`, `wallet_profiles`, and `project_retention`
- published tools such as `kpis`, `lifecycle`, `top_events`, `top_pages`, `top_sources`, `top_locations`, `revenue_overview`, `retention`, and `search_profile`
- `search_formo_docs` and `query_docs_filesystem_formo_docs` for grounded product and API questions

The live endpoint list varies by project. Prefer discovery over assuming a tool exists.
Expand Down Expand Up @@ -87,9 +87,11 @@ Use `--timestamp <ISO-8601>` with `profiles get` to return the stored wallet-enr

## Manage Formo resources

The MCP server exposes management tools for the same resources — alerts, boards/charts (including `reorder_charts` and `execute_saved_chart`), contracts, segments, profile writes, and wallet import — each gated on the matching `*:read`/`*:write` scope, with destructive tools requiring `"confirm": true`.

Use CLI command groups for operational resources:

- `formo alerts` for alert list/get/create/update/delete/toggle/test
- `formo alerts` for alert list/get/create/update/delete/toggle
- `formo boards` and `formo charts` for dashboards and visualizations
- `formo contracts` for tracked contract configuration and pipeline inclusion
- `formo segments` for saved audiences
Expand Down
69 changes: 1 addition & 68 deletions src/commands/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
import { isCanonicalFilterOperator } from '../lib/filters'
import { parseJsonArray, parseJsonObject } from '../lib/json'
import { parseJsonArray } from '../lib/json'
import {
buildPaginationParams,
paginationOptionsSchema,
Expand Down Expand Up @@ -262,70 +262,3 @@ alerts.command('toggle', {
},
})

// ── Test alert delivery ──

export interface TestAlertOptions {
sampleEvent?: string
sampleUser?: string
recipientOverrides?: string
}

export function buildTestAlertBody(options: TestAlertOptions) {
const body: Record<string, unknown> = {}
if (options.sampleEvent !== undefined) {
body.sampleEvent = parseJsonObject(options.sampleEvent, '--sample-event')
}
if (options.sampleUser !== undefined) {
body.sampleUser = parseJsonObject(options.sampleUser, '--sample-user')
}
if (options.recipientOverrides !== undefined) {
body.recipientOverrides = parseJsonArray(
options.recipientOverrides,
'--recipient-overrides',
)
}
return Object.keys(body).length > 0 ? body : undefined
}

export function testAlertRun(alertId: string, options: TestAlertOptions = {}) {
requireApiKey()
const client = createClient()
return client.post(
`/v0/alerts/${encodeURIComponent(alertId)}/test`,
buildTestAlertBody(options),
)
}

alerts.command('test', {
description: 'Send a test delivery for an alert',
args: z.object({
alertId: z.string().describe('Alert ID to test'),
}),
options: z.object({
sampleEvent: z
.string()
.optional()
.describe('Optional JSON object to use as the sample event'),
sampleUser: z
.string()
.optional()
.describe('Optional JSON object to use as the sample user/profile'),
recipientOverrides: z
.string()
.optional()
.describe('Optional JSON array of recipient objects to test instead of saved recipients'),
}),
examples: [
{
args: { alertId: 'alert_abc123' },
options: {
sampleEvent: '{"event":"transaction","revenue":250}',
},
description: 'Send a test alert with a sample event',
},
],
hint: 'Requires alerts:write scope on your API key.',
run({ args, options }) {
return testAlertRun(args.alertId, options)
},
})
25 changes: 20 additions & 5 deletions src/commands/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,29 +134,43 @@ const chartBodyOptions = z.object({
export function listChartsRun(
boardId: string,
options: PaginationOptions = {},
results = false,
) {
requireApiKey()
const client = createClient()
return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, {
params: buildPaginationParams(options),
params: {
...buildPaginationParams(options),
// The public API returns lightweight summaries unless asked to execute.
...(results ? { include: 'results' } : {}),
},
})
}

charts.command('list', {
description: 'List all charts for a board, including executed results',
description:
'List charts for a board (summaries by default; --results executes each chart query)',
options: z.object({
boardId: z.string().describe('Board ID to list charts from'),
results: z
.boolean()
.optional()
.describe('Execute each chart query and include results (slower)'),
...paginationOptionsSchema,
}),
examples: [
{
options: { boardId: 'board_abc123' },
description: 'List all charts in a board',
description: 'List chart summaries for a board',
},
{
options: { boardId: 'board_abc123', results: true },
description: 'List charts with executed query results',
},
],
hint: 'Requires boards:read scope on your API key.',
run({ options }) {
return listChartsRun(options.boardId, options)
return listChartsRun(options.boardId, options, options.results ?? false)
},
})

Expand All @@ -168,7 +182,8 @@ export function listChartSummariesRun(
) {
requireApiKey()
const client = createClient()
return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/meta`, {
// Summaries are the list endpoint's default; /charts/meta is internal-only.
return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, {
params: buildPaginationParams(options),
})
}
Expand Down
76 changes: 1 addition & 75 deletions src/commands/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type { PaginationOptions }

export const contracts = Cli.create('contracts', {
description:
'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts',
'Smart contract commands — register, list, update, and remove tracked contracts',
})

function parseChain(chain: string | number) {
Expand Down Expand Up @@ -52,29 +52,6 @@ export function getContractRun(chain: string, address: string) {
)
}

// ── Recommended contracts ──

export function getContractRecommendationsRun() {
requireApiKey()
const client = createClient()
return client.get('/v0/contracts/recommendations')
}

contracts.command('recommendations', {
description:
'List contracts the project already interacts with but has not added yet',
options: z.object({}),
examples: [
{
description: 'Show recommended contracts to add for decoding/monitoring',
},
],
hint: 'Requires contracts:read scope on your API key.',
run() {
return getContractRecommendationsRun()
},
})

contracts.command('get', {
description: 'Get a tracked contract by chain and address',
args: z.object({
Expand Down Expand Up @@ -243,57 +220,6 @@ contracts.command('update', {
},
})

// ── Toggle contract pipeline inclusion ──

export function updateContractPipelineRun(
chain: string,
address: string,
includeInPipeline: boolean,
) {
requireApiKey()
const client = createClient()
return client.patch(
`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}/pipeline`,
buildUpdateContractPipelineBody(includeInPipeline),
)
}

export function buildUpdateContractPipelineBody(includeInPipeline: boolean) {
return { include_in_pipeline: includeInPipeline }
}

contracts.command('pipeline', {
description:
'Toggle whether a tracked contract is included in the project events pipeline',
args: z.object({
chain: z.string().describe('Chain ID'),
address: z.string().describe('Contract address (0x...)'),
}),
options: z.object({
includeInPipeline: z
.boolean()
.describe('true to include the contract in the pipeline, false to exclude it'),
}),
examples: [
{
args: {
chain: '1',
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
},
options: { includeInPipeline: false },
description: 'Keep ABI decoding but exclude this contract from pipeline deploys',
},
],
hint: 'Requires contracts:write scope on your API key.',
run({ args, options }) {
return updateContractPipelineRun(
args.chain,
args.address,
options.includeInPipeline,
)
},
})

// ── Delete a contract ──

export function deleteContractRun(chain: string, address: string) {
Expand Down
26 changes: 1 addition & 25 deletions test/commands/bodyBuilders.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { expect } from 'chai';
import { buildAlertBody, buildTestAlertBody } from '../../src/commands/alerts';
import { buildAlertBody } from '../../src/commands/alerts';
import { buildBoardBody } from '../../src/commands/boards';
import {
buildChartBody,
normalizeDuplicateChartResponse,
} from '../../src/commands/charts';
import {
buildCreateContractBody,
buildUpdateContractPipelineBody,
buildUpdateContractBody,
} from '../../src/commands/contracts';
import { buildImportBody } from '../../src/commands/import';
Expand Down Expand Up @@ -106,21 +105,6 @@ describe('commands / body builders', function () {
});
});

describe('buildTestAlertBody()', function () {
it('parses sample objects and recipient overrides', function () {
const body = buildTestAlertBody({
sampleEvent: '{"event":"transaction"}',
sampleUser: '{"address":"0xabc"}',
recipientOverrides: '[{"type":"email","value":["a@b.com"]}]',
});
expect(body).to.deep.equal({
sampleEvent: { event: 'transaction' },
sampleUser: { address: '0xabc' },
recipientOverrides: [{ type: 'email', value: ['a@b.com'] }],
});
});
});

// ── Boards ──

describe('buildBoardBody()', function () {
Expand Down Expand Up @@ -240,14 +224,6 @@ describe('commands / body builders', function () {
});
});

describe('buildUpdateContractPipelineBody()', function () {
it('maps includeInPipeline to include_in_pipeline', function () {
expect(buildUpdateContractPipelineBody(false)).to.deep.equal({
include_in_pipeline: false,
});
});
});

// ── Segments ──

describe('buildCreateSegmentBody()', function () {
Expand Down