MCP.so
Sign In

Porter Metrics MCP ServerVerifiedFeatured

@Porter Metrics Inc

Basic information

Transport

Streamable HTTP

Authentication

OAuth

Last updated

Category

Data & Analytics

Author

Porter Metrics Inc

Submitted by

Master From Porter

About Porter Metrics MCP Server

Porter Metrics is a hosted MCP server for marketing data. Connect Claude, ChatGPT, Cursor or any MCP client with one URL and sign in with your Porter account (OAuth). No API keys or Google Cloud project. Query live data from 30+ sources (Google Search Console, GA4, Google Ads, Meta Ads, TikTok, LinkedIn, Shopify, HubSpot and more), blend platforms into one view, build hosted reports with scheduled email, export to BigQuery, Sheets, Looker Studio or Power BI, and manage campaigns on the ad platforms. Free-forever plan available.

Connection details

https://mcp.portermetrics.com/mcp

Setup

claude mcp add porter-metrics --transport http https://mcp.portermetrics.com/mcp

Porter Metrics MCP Server tools

45

Create a trigger that runs an action on an event or a cron schedule. kind: "event" (match {event_type, condition}) or "schedule" (match {cron, timezone, payload}). action: {kind: "tool"|"emit"|"agent", target_id, args_template?} (emit uses {emit_type, data_template}). For schedules the first occurrence is armed automatically. A daily routine (Porter doing work on its own every morning) is this tool with kind="schedule". Emailing an existing report on a cron is a different thing: execute_action("report_email_schedule.create"). Before proposing a routine, read get_knowledge(queries=["porter-metrics-routine"]).

List the caller's triggers, optionally filtered by kind.

Get a trigger by id.

Pause a trigger (cancels the in-flight schedule task if any).

Resume a paused trigger (re-arms the next schedule occurrence).

Delete a trigger permanently.

Mint a signed user webhook URL. POSTing a body with a valid X-Porter-Signature (HMAC-SHA256) fires `event_type` for you.

Delete a signed webhook.

Emit an event into the mesh (fans out to matching triggers). Lets a tool or agent chain work by raising a new event.

List recent executions of a trigger (audit/idempotency log).

Identify the authenticated Porter user and the company you are acting on. Useful context before any account, blend or report operation — it tells you whose data you are working with and on which plan. `company.plan_status` says what that plan is worth right now: `active_subscription` (or `active_subscription_other_company`, an agency plan covering this workspace), `no_active_subscription` (nothing paid is in force — this is what a `quota_exhausted` query looks like from the licence side) or `unknown` (the billing summary could not be read, so the null `plan` means nothing). `skill`: if the Porter Metrics skill is loaded, pass the value it gives you (e.g. "porter-metrics@2.3.0"). When the answer carries a `skill_notice`, follow its `instructions`.

List Porter's data connectors and export destinations — the sources and targets that feed query_data, blends and hosted reports. Covers Porter's marketing connectors (Meta Ads, Google Ads, GA4, Shopify, …) and the export destinations (BigQuery, Google Sheets, Looker, Power BI). Narrow with a `query` (name substring), a `category` (advertising, analytics, databases, ecommerce, payments, crm, email, files, seo, destination), or `connected_only` to show just what the user already connected. Paginated with `cursor`. This list is REPORT-CAPABLE connectors ONLY. Third-party automation MCPs (Notion, Stripe, Sentry, Slack, Linear, …) are NOT connectors and are deliberately excluded here: they bring no analytical data and cannot be a data source for query_data, blends or hosted reports. They are integrations with live actions — list the connected ones, or with `include_available=true` the whole catalog, via `list_integrations`.

List the accounts, destinations or connections for one connector. For a marketing connector (`facebook-ads`, `google-ads`, …) this returns the user's accounts. When the request names an account, ALWAYS pass `query`: a confident name or id match comes back on its own, so you can pass one `account_id` downstream instead of every account the user has. When nothing matches confidently you get the full list back, ranked, plus a `hint` — so searching costs you nothing. BigQuery, Google Sheets and `sheets-source` are walked one level at a time via `parent_account_id`: BigQuery `authorization → project → dataset → table`, Google Sheets `destination → spreadsheet → worksheet`, `sheets-source` `spreadsheet → worksheet` (a Google Sheets SOURCE account is a worksheet, never a whole spreadsheet). For a third-party MCP it returns the user's connections. For `facebook-public-data` (competitor Pages) pass the page's username, numeric id or URL as `query`: a page nobody has queried yet comes back as an `available` row whose `account_id` `query_data` already accepts. Every row says whether it is the end of that walk. `is_leaf: false` means it is a step, NOT an account — feed its `account_id` back as `parent_account_id` to go deeper. A row without `is_leaf: false` carries an id the other tools accept; passing a step id to `query_data`, `execute_action` or `create_report` is rejected. The exception is a DESTINATION connector (`google-sheets`): its walk ends in a tab you write TO. No row of it is an account for `query_data` or `create_report`; any row of it is the `account_id` of that connector's own write actions, and the leaf, split, goes into an export's destination params. Each page says which case it is. Each row's `account_id` is the opaque reference you pass verbatim to `query_data` and `execute_action` (it binds the account's identity so it can't be misused). The row ALSO carries readable fields for you and the user to identify the account — `name`, `native_account_id` (the platform's own id, e.g. `act_…`), `company_id` and `source_user_id`. Use these to tell apart accounts that share a name (the same account can appear under several companies) — but always pass the `account_id` downstream. `source_user_email` is null on connected rows, so do not identify an account by it. Pass `verbose=false` for compact rows: `company_id` and `source_user_id` only where the same account repeats, and `connector`/`source` only when they differ from the connector you asked for.

Start connecting an account for a connector, destination or MCP. Returns a browser `authorization_url` the user opens to authorize (OAuth), or `instructions` when the connector needs a different method. For a BigQuery service account it returns instructions plus a `follow_up` to the action that ingests the key — the secret never travels through this tool. `role` disambiguates BigQuery: `role='source'` connects it as a data source (read the user's own tables); the default connects it as a destination (write results). Ignored for every other connector. `company_id` says which company the authorization should land in. Pass it whenever the user holds a role in more than one company — reconnecting an account that lives in a client's or a colleague's company is exactly that case. `list_accounts` reports the `company_id` of every account, so reconnect a broken one with the company it is already in; otherwise the authorization can land somewhere else and leave the broken one untouched.

List the integrations (MCPs) THIS user can use, with the live actions each one currently offers. With `include_available=true` it also lists the integrations that can still be CONNECTED from Porter's catalog. An integration belongs to the PERSON who connected it. It appears here only if it is the user's own or its owner explicitly shared it: with this person, with their company, or —where this deployment has Porter's older company sharing enabled for integrations— by sharing with this user (any role) the Porter company it was connected under, which then hands over everything in that company, personal integrations included. This list is the authority on what the user can use. A teammate having connected something does NOT make it available: if it is not in this list, tell the user so, and that either they connect their own (`connect_account`) or the owner shares it from the Porter hub (MCPs → the connection → Share). Do not retry and do not report it as a bug. `is_mine: false` means its owner shared it. The call then runs AS that owner's account (their Drive, their mailbox), so say whose it is when you answer, prefer the user's own connection for anything that writes, and when several connections of the same integration exist and the request does not point to one, ask which to use naming each account. Each row carries `name` (the label of that connection), `product`, `owner_user_id` (who connected it in Porter) and, when known, `account` (the provider account) and `workspace` (e.g. the Notion workspace). If `account`/`workspace` are missing, say you do not know them — do not guess from `owner_user_id`. This is a different thing from `list_connectors`, and the difference decides which one you want: * `list_connectors` — Porter's **reporting** connectors. They bring historical data you can query, blend and put in a report (`query_data`, blends, hosted reports). They have no actions. * `list_integrations` — **integrations** the user connected (Stripe, Jira/Atlassian, Google Drive, …). They execute actions live against that third-party product. They are not a data source for a report. Several names exist on BOTH sides — "stripe" is a reporting connector AND an integration. Asking "how much did we charge last month" is the reporting connector; asking "refund this payment" is the integration. When in doubt, look here first: what an integration can do is whatever it reports right now, which is why this list is read live and not from a stored catalog. The tool list is asked to each MCP at call time, so it is always current: an MCP that adds or removes actions is reflected immediately. `include_available=true` answers "what can I connect?" / "which connectors do you have?": `available` lists the catalog integrations the company has NOT connected yet (name, category, how it authenticates), without actions — those appear once connected. They are connected from the Porter hub. For the analytical side of that question, pair it with `list_connectors` and present the two groups separately: reporting connectors (data for reports) vs integrations (live actions). `query` narrows both lists by id, name, category AND description, so a product that lives inside another integration is found by its own name: `query="jira"` returns Atlassian ("Jira issues and Confluence pages"). Search here before telling the user an integration does not exist. `assistant_integrations` lists what the Porter hub shows as included in the user's Porter assistant (Town, Slack, Teams). Those run through that assistant, not through this connection: say so instead of reporting them as missing.

Disconnect a third-party integration the user previously connected. Removes a catalog MCP connection and its stored credential. Disconnecting a Porter marketing account (a SourceUser) is not supported here — manage those from the Porter app.

Look up Porter know-how: skills, workflows, error→fix patterns and FAQs. Pass `queries` as 3-5 rephrasings of the SAME intent (vary verb, synonyms, language, granularity) — they fuse into one ranked result set. Use it at the start of a use case, for unknown errors or provider quirks, or to recall how a Porter flow works. Optionally hard-filter by `domain`. With `compact=true`, `limit` counts only entries WITH a body, rows that are only a pointer (tools, workflows, error codes) come back separately in `related_entries`, and each entry carries its `slug`. When you already know which entry you want — a skill names another by its slug ("see `meta-campaign-flow-setup-and-adsets`"), or an earlier result carried a `slug` — pass `slugs=[...]` (up to 8; full ids also work) instead of searching: it returns those entries with their full body, no ranking. Returns `{entries: [{kind, title, summary, body?, related}]}`.

Reserve a single-use signed URL for a heavy upload (a local file). Five purposes: - `purpose='action'` — the action itself takes bytes (`facebook_ads.image_upload`, `facebook_insights.photo_upload` / `video_upload`, a custom-audience CSV). Pass `action` (the SAME ref list_actions/execute_action use) and the `account_id` from `list_accounts`. POSTing the bytes RUNS the action and returns its result. Refused with a hint when the action is URL-only. - `purpose='public_url'` — the action only fetches a URL (Instagram `media_create` image_url/video_url, any `*_url` param). Pass `path` as the file name (e.g. 'photo.jpg'); the POST answers with a public, permanent `asset_url` to pass to the action. Set `convert_to_jpeg` in the POST for Instagram images (JPEG only). - `purpose='report_asset'` — an image inside a hosted report bundle; pass `report_id` and `path`. - `purpose='report_source'` — a file `publish_report_spec` takes by reference instead of in its arguments: a custom section's TSX (`path='Ticker.tsx'`), a spec or a JSON patch (`.json`). POST `{"params": {"content": "<text>"}}`; the answer's `source_ref` goes in `custom_sections_ref` / `spec_ref` / `patch_ref` (yours alone, 2 h, ≤ 256 KB). - `purpose='domain_logo'` — the brand logo of a connected custom domain (PNG/JPEG ≤ 512 KB; shown as the browser-tab icon and on the sign-in page under that domain). Pass `hostname` (from manage_report_domain list) and `path` (the file name, e.g. 'logo.png'). Owner/admin + paid plan. Returns `{upload_url, method, headers, expires_in, max_size_bytes, input_schema, how_to_post, next_step}`; POST the JSON body to `upload_url` from a code-execution channel (never through the chat).

List the metrics and dimensions you can query, for a connector or the blend. With no ``connector`` you get Porter's curated cross-connector catalog (unprefixed names like ``clicks``, ``amount_spent``). Pass a connector slug (e.g. ``"facebook-ads"``) for that platform's full schema. Filter by ``field_type`` (``metric`` or ``dimension``) or a ``query`` keyword. When ``account_id`` is supplied and the connector supports them, the account's user-defined custom fields (e.g. Google Ads conversion actions) are merged in with ``custom: true``. Every returned ``id`` is a valid field name for ``query_data`` / ``query_blend``. ``exact_match``, when present, is the ONE field whose name or label is exactly what you searched for — use it and skip reading the rest. ``covers``, when present, lists the connectors a field actually resolves against — a blend field asked of a connector outside that list answers with zeros, not with an error. ``porter_blend_alternatives`` names the blend fields that do cover the connector you asked for. ``refresh_schema=True`` (with an ``account_id``) makes the connector re-read the account's schema from the platform instead of its cache. The cache is refreshed automatically when the account is re-authorized in Porter; use this only when the user just created something on the platform (e.g. a custom conversion) and needs to see it now. It is slower, so leave it off by default.

Run an ad-hoc query across one or more connected accounts. You can skip ``list_accounts`` in two cases, both needing ``connector``: * the user NAMES an account — ``accounts=["Glamping Tabio"], connector="facebook-ads"`` resolves it against their authorized accounts. A name matching nothing, or two different accounts, is rejected rather than guessed. * the user wants them ALL — ``accounts=["*"], connector="facebook-ads"`` expands to every usable account, so you never type out one id per account. Otherwise pass the signed ``accounts`` refs from ``list_accounts`` verbatim (they bind each account's identity so you never assemble ids by hand), the ``metrics`` and ``dimensions`` from ``list_fields``, and a ``date_range`` (a preset like ``{"preset": "last_30_days"}`` or explicit ``{"from": "2026-01-01", "to": "2026-01-31"}``) — pass EITHER a preset OR explicit bounds, never both. Optional ``filters`` is a list of ``{"field": <name from list_fields>, "operator": <op>, "value": <value>}`` combined with AND, where ``<op>`` is one of ``eq|neq|gt|gte|lt|lte|contains|ncontains|in|null|notnull``. One side effect worth knowing before you fan out: an account listed as ``available`` (not yet ``connected``) is MATERIALIZED the first time query_data runs against it, and each materialization takes a licence slot from the workspace. The read itself changes nothing the caller owns, but querying a whole listing to see which accounts have data does spend slots. Query what the user asked about. Accounts from different connectors are blended into shared columns. Returns ``{columns, rows, row_count, truncated}``. Read-only. Each row is a positional tuple aligned with ``columns``, and ``columns`` comes back in the order you asked for (``metrics`` first, then ``dimensions``) — always read values through ``columns`` rather than assuming a layout. ``limit`` is the page size, not a cap on the result. ``total_rows`` is the size of the whole result; when it holds more rows than ``limit`` the response carries a ``next_cursor``. Call again with the SAME arguments plus ``cursor=<next_cursor>`` to read the next page, and repeat until ``next_cursor`` is absent — pages come from the same run, so nothing is re-extracted and no row is skipped or repeated. Add up every page before reporting a total. ``total_rows_is_lower_bound: true`` means the result is larger than Porter reads in one query: narrow it (shorter range, fewer accounts, a filter) instead of paging. A cursor lasts 24 hours. Presets resolve against the UTC calendar day; ``last_7_days`` / ``last_30_days`` end yesterday, while ``today`` / ``this_month`` / ``this_year`` include the incomplete current day. ``warnings``, when present, flag ranges that include the not-yet-ingested current day for storage-backed connectors — surface them to the user instead of presenting the numbers as final; ``data_freshness`` reports ``lastRefreshed`` / ``coverageUntil`` of the stored data. **When the user names a connector but no account, do NOT pick one.** Pass every account you have for that connector and add the connector's account-name dimension (e.g. ``facebook_ads_account_name``). One call then returns both the figure and WHICH account it belongs to, which is the question you were about to search for. Trying accounts one at a time to see which has data costs minutes and answers the same thing. A query that outlives one tool call comes back as ``{"status": "running", "query_id": …}`` instead of rows. That is NOT an error and nothing was lost: call ``get_query_result(query_id=…)`` with that id copied verbatim, and call it again immediately if it says running again. Never re-issue this call to answer a running one — that abandons the run in flight and starts the extraction over. ``coverage`` says whether the result covers every account you asked for (``complete: true``) or some were dropped because the platform denied access, naming them. **Read it and stop**: a complete result has nothing further to find, …

Collect the result of a ``query_data`` call that was still running. Pass the ``query_id`` verbatim from a ``{"status": "running"}`` response. This attaches to the SAME extraction already in flight — it does not start a second one — and waits for it, so calling it costs nothing beyond the wait. Returns the normal ``{columns, rows, row_count, truncated}`` once the data lands, or another ``{"status": "running", query_id}`` if it is still going. A result with more rows than ``limit`` carries a ``next_cursor``: pass it back here as ``query_id`` to read the next page. **Call it again immediately when it says running.** There is no need to pause between calls: each one blocks on the run rather than polling it, so waiting before you ask only delays the answer by however long you waited. Never answer a still-running query by re-issuing ``query_data``: that abandons the work in progress and starts the extraction over.

Combine several BigQuery tables into one queryable join, on the fly. BigQuery-only. Pass the signed ``tables`` refs from ``list_accounts`` (2–5 tables of the SAME BigQuery connection) plus how to join them, and get back a single ``account_ref`` you use in ``query_data`` exactly like a normal account — the tap runs the JOIN inside BigQuery. This does NOT create a new account: the ref is ephemeral (it only encodes the join order) and is discarded after the query. Result field names stay prefixed per source table (``bigquery_<dataset>__<table>__<column>``) so columns from different tables never collide. ``joins`` is a list of clauses ``{"left": <table index>, "right": <table index>, "type": "INNER"|"LEFT"|"RIGHT"|"FULL", "on": [["left_col", "right_col"], …]}`` — one clause per extra table; the ``on`` pairs are ANDed. ``date_table``/``date_column`` choose which table's date column the ``query_data`` date range filters on. Returns ``{account_ref, name, tables}``. If a join key is missing or the paired columns' types don't match, ``query_data`` will say so.

Run a saved blend by id and return its rows. The blend already holds its account set. By default it projects the blend's saved metrics + dimensions; pass ``fields`` (names from ``list_fields``) to project a specific subset. Supply the time window (``date_range`` — preset or ``{"from", "to"}``), optional ``filters`` (operators ``eq|neq|gt|gte|lt|lte|contains|ncontains|in|null|notnull``), and an optional ``limit``. Discover blend ids with ``list_blends``. Returns the same ``{columns, rows, row_count, truncated}`` shape as ``query_data``, and pages the same way: ``limit`` is the page size, and a ``next_cursor`` is read by calling again with the same arguments plus ``cursor``. If a blend errors because its schema is not warmed, call ``update_blend(blend_id, refresh_schema=true)`` and retry. A blend is the slowest kind of query, so it is the likeliest to come back as ``{"status": "running", "query_id": …}`` — that is not an error: collect it with ``get_query_result(query_id=…)``, calling again immediately while it says running, and never by re-issuing this call. Read-only.

List the user's saved blends, paginated and optionally filtered by name. A blend is a saved cross-connector report. Pass ``query`` to match on name and keep the response small; page with the opaque ``cursor`` from a prior response. Returns ``{blends: [{blend_id, name, status, data_sources, updated_at}], next_cursor}``.

Inspect one blend: its definition, exports, and recent runs. Returns ``{blend, schema, exports, recent_executions}`` — the blend record (name, status, data sources, accounts), its exports/schedules (one per destination), and the newest execution rows. ``schema.custom_metrics`` lists the blend's saved calculated fields; each ``name`` is queryable with ``query_blend``. Use this before editing a blend or to answer "when did this last run?". Each export carries its saved settings as stored — ``query_name``, ``metrics``, ``dimensions``, ``date_range``, ``filters``, ``sort``, ``destination_settings`` (spreadsheet/worksheet or dataset/table, write mode, start cell, and for Sheets ``replace_null_with_zero``) and ``data_source_ids`` — so an export can be read without running or changing it, plus ``last_run`` / ``last_run_status`` / ``last_run_error`` of its newest execution. An empty ``schedule`` means the export only runs on demand. The run reads its data from the blend.

Create a saved blend — a named cross-connector container of accounts. Pass a ``name``, the connector ``data_sources`` (slugs from ``list_connectors``), and the ``accounts`` (signed refs from ``list_accounts``, verbatim). A blend by itself is inert; attach a recurring export via the Actions surface. ``destination_type`` (``"bigquery"`` — the default — or ``"google_sheets"``) is where every export built on this blend writes. It is baked in at creation and CANNOT be changed later, so confirm it with the user when they mean to land the data in a spreadsheet. Returns ``{blend_id, name, destination_type, schema, warnings?}``. If the schema warm-up needs a re-auth, the ``warnings`` carry the URL to surface to the user.

Edit a blend and optionally rebuild its schema. Pass only what changes: ``name``, ``status``, or a ``fields`` object with ``data_sources`` / ``add_accounts`` / ``remove_accounts`` (accounts as signed refs from ``list_accounts`` or full dicts from ``get_blend``). Set ``refresh_schema=true`` to re-warm the blend's schema after the edit (or on its own if a query complained the schema was not warmed). Returns ``{blend_id, updated_fields, schema?}``.

Permanently delete a blend and everything under it. Removes the blend plus all its exports, schedules and run history — this cannot be undone, so confirm with the user first. To stop a scheduled export without deleting the blend, disable that export instead. Returns ``{status, deleted_exports_count}``.

List, create, edit or delete the custom metrics (calculated fields) saved on a blend. A custom metric is a formula over the blend's fields, saved once and usable in ``query_blend``, reports and exports like any other field. ``action`` is one of ``list | create | update | delete``. * ``create`` needs ``label`` and ``formula``. Optional: ``kind`` (``metric`` — default — or ``dimension``), ``data_type`` (NUMBER for metrics; STRING or BOOLEAN for dimensions), ``default_aggregation`` (SUM, AVG, MAX, MIN, COUNT, COUNT_DISTINCT; metrics only), ``format`` (``number|percent|currency_usd`` for metrics, ``text|boolean`` for dimensions) and ``formula_prompt`` (the user's own words, kept for reference). * ``update`` needs ``metric_id`` plus only what changes. ``delete`` needs ``metric_id`` and is permanent — confirm with the user first. Every write is validated before saving (syntax, that each referenced field exists in this blend, type/aggregation coherence) and re-warms the blend schema. Each metric comes back with ``name``: pass that id in ``query_blend(fields=[...])``. Formula syntax (the engine's, exactly): reference fields by their bare id from ``list_fields(connector=...)`` (``<connector>_<field>``; unprefixed cross-connector ids only in blends with 2+ sources) or by another metric's ``name``. Operators ``+ - * /``, comparisons ``= != < <= > >=``, ``AND OR NOT``, ``IN ('a','b')``, ``IS NULL``, text in quotes. Aggregations SUM AVG MAX MIN COUNT COUNT_DISTINCT; conditionals ``IF(cond, a, b)``, ``IFNULL(x, 0)``, ``CASE WHEN … THEN … ELSE … END``; functions ABS SQRT ROUND CONCAT SUBSTR REGEXP_MATCH REGEXP_CONTAINS REGEXP_EXTRACT REGEXP_REPLACE PARSE_DATE CURRENT_DATE FORMAT_DATE YEARWEEK WEEKDAY WEEK QUARTER MONTH DAY TOCOUNTRY. Names are UPPERCASE. Write ratios as ratios of totals: ``SUM(<connector>_cost) / SUM(<connector>_conversions)``, not ``cost / conversions``.

List the hosted Porter reports for the current workspace. ``scope`` picks whose reports: ``"owned"`` (default) = your company's, ``"shared_with_me"`` = reports other users shared with you (each carries ``owner_email`` and your ``role``, viewer|editor|admin), ``"all"`` = both. Use ``shared_with_me`` when the user asks about reports shared with them — shared reports can be read, previewed and duplicated (duplicate_report). Each entry carries its ``report_id``, ``name``, ``status``, public ``url`` and ``updated_at`` so you can sort by newest or last-edited and pick one to read, preview, edit, share or delete. ``query`` filters by name substring. Results are paginated: pass the returned ``next_cursor`` to fetch the next page, and stop when it is null.

Read a report's metadata + structure (and, for a built report, a link to download its project). Fast — it does NOT run the report. Returns the report's ``report_id``, ``name``, ``health`` (OK | DEGRADED | BROKEN | UNKNOWN — ``UNKNOWN`` is no health signal yet (it never ran or published a version), NOT healthy. Only ``preview_report`` proves the charts actually render; an account the report uses that is no longer active in Porter lowers it to DEGRADED, or BROKEN when all are, and is named in ``disconnected_accounts`` with a reconnect ``hint``), ``status``, public ``url`` and ``updated_at``, its ``pages`` (each ``{page_id, name}``), its ``account_slots`` (each labeled account: ``{label, account_id, account_name, connector}``), ``params`` (the values that name its client) and ``reads_params`` (whether the report reads those params at all: false means a copy keeps this client's brand; null, not audited since it was recorded) — what ``duplicate_report`` re-points for a copy on another account —, the ``bundle_manifest`` (the report's file/version index) and a signed ``download_url`` (+ ``download_expires_at``). A report published with ``publish_report_spec`` is changed with ``publish_report_spec(report_id=…, patch=[…])``. To change a built (zip) report (or fix a duplicate): POST ``download_url`` from your code-execution tool to fetch its project zip, unzip, edit, ``next build``, then re-upload via ``edit_report``'s ``upload_url``. To debug a broken chart: ``preview_report``.

Render the report against real data and return the preview plus diagnostics. Right after an upload you usually do NOT need this: ``edit_report`` and ``publish_report_spec`` already return the report's ``diagnostics`` and a ``preview_url`` from the audit they ran against real data. On a report that has not changed since, this returns that same audit in about a second (``cached: true``, with ``audited_at``) instead of rendering again. Pass ``refresh=True`` only when the DATA may have changed since then (an account was reconnected, a sync finished) — it forces a full render, which is SLOW (5-30s or more). Use it to validate the render or debug broken charts — not for routine listing or metadata reads (use ``get_report`` for those). Because the render already produces the logs, this ABSORBS the diagnostics: one call returns a ``preview_url`` (an openable link to the rendered preview — give it to the user), ``rendered_at``, ``diagnostics`` (each a broken or empty chart with ``severity``, optional ``page_id``/``chart_id``, a ``message`` and a ``hint`` on how to fix it), ``cached`` and, when known, ``audited_at`` (when the audit behind the answer ran). A ``page_id`` always renders that page. ``ready`` is true only when a preview exists AND no chart failed; ``preview_available`` says whether the image exists at all. When ``data_status`` is ``query_budget_exhausted`` the charts hit the report's hourly query budget: do not edit or re-upload, retry after ``retry_after_seconds``. ``page_id`` scopes the diagnostics only. The preview image is ALWAYS the report's default first page (captured at build time), whatever page you ask for: with ``page_id`` the result says so in ``page_note`` and gives a ``page_url`` that opens the requested page — share that link for it.

Create a hosted Porter report and get the build instructions to author it. This is the SLOW path (you build a bundle and upload it: minutes). A NEW report over the user's accounts goes the fast way: ``draft_report_spec`` → ``publish_report_spec(draft_id=…, patch=[…])``, live in about a minute with any theme, the free layout, ``viz`` nodes for any chart and custom sections. Use this tool only for what the spec cannot express, or a self-contained report (below). A report is a full front-end project you build against a data simulator and ship. Provide a ``name`` — it is the only human label Porter stores, so put the identifying text there. ``description`` is NOT persisted (the result says so in ``warnings``). Optionally a ``template`` (the starter to build from), and the report's data allowlist — ``accounts_used`` (the accounts its charts may query, each just the ``account_id`` from ``list_accounts`` — the ref is expanded to the full account automatically) and ``connectors_used`` (defaults to the accounts' connectors). These may be passed top-level or nested inside ``pages`` — both are honored. Returns the new ``report_id``, its public ``url``, and ``edit_hints`` — the authoring brief and build/upload instructions to fill the report with charts (then finalize with ``edit_report``). Omit both when the report is SELF-CONTAINED — its data was gathered while building and is frozen into the bundle, so it queries nothing when viewed (e.g. a competitor teardown built from public sources, which has no Porter account behind it). Sharing: a report is born PRIVATE to its owner. When it is built for a team or a client, share it here instead of with a later ``share_report`` call — ``share_with_domain=True`` opens it to everyone signed in with the owner's email domain (refused for public providers such as gmail.com), and ``allowed_emails`` names people one by one. ``params`` stores the values that name ONE client — its name, logo, campaign, budgets — on the report instead of in the build. The report reads them with ``useReportParams(defaults)``, so ``duplicate_report`` can later make the same report for another client without a rebuild. Anyone who can open the report can read them, so nothing its viewers should not see.

Publish a hosted report from a spec — no build, no zip, live in seconds. The report is DATA: Porter drops the ``spec`` onto its prebuilt viewer, checks every chart against real data and returns the live ``url``. A report is ALWAYS this hosted Porter report: an error is fixed with its hint, never by building it elsewhere (an HTML page, an artifact). Pass ``name`` and ``accounts_used`` (``account_id`` values from ``list_accounts``; the connectors are derived) to create a report, or ``report_id`` to change one (below). Start from ``draft_report_spec(intent, accounts_used, sections=[…])``: it returns the spec with what was asked, passing these checks, and its ``draft_id``; publish it with ``publish_report_spec(draft_id=…, patch=[…])`` — name and accounts come with it, the patch holds only your changes; never resend the whole spec. A chart the catalog lacks is a ``viz`` node (below), a control or other visual a custom section — never a reason to leave; only what neither expresses goes to the slow path, ``create_report`` + ``edit_report``. This description is the reference (``get_knowledge`` only for the full ``@porter/kit`` list). One page of at most eight sections by default; more pages or sections only when the user asks for them: every chart runs against real data before this call answers. A complete one-page spec:: {"meta": {"title": "Google Ads", "dateRange": {"start": "2025-12-01", "end": "2025-12-31"}}, "connector": "google-ads", "pages": [{"id": "overview", "title": "Overview", "sections": [ {"type": "kpi_row", "metrics": [ {"field": "cost_micros", "label": "Spend", "format": "money"}, {"derive": "ratio", "from": ["cost_micros", "clicks"], "label": "CPC", "format": "money", "lowerIsBetter": true}]}, {"type": "trend", "metric": "clicks", "title": "Clicks per week", "granularity": "week"}, {"type": "table", "dimension": "campaign_name", "title": "Campaigns", "columns": ["cost_micros", "impressions", "clicks"], "derived": ["ctr"]}]}]} Fields are ``list_fields`` ids; the connector's prefix may be left out when one id has it (``clicks`` = ``google_ads_clicks``); ratios are never fields, derive them. ``format`` is exactly ``money``, ``int`` or ``pct``. A fixed period (a month, a quarter) is ``meta.dateRange``; ``defaultPreset`` is only ``7d|30d|90d``. ``spec`` = ``{meta, connector, pages, theme?, filters?, layout?}``. ``meta``: ``title``, ``subtitle?``, ``dateRange?`` ``{start, end}`` (ISO) or ``defaultPreset?``, ``compareDefault?``, ``locale?`` (BCP-47), ``currency?`` (ISO 4217), ``periodControl?``, ``client?``, ``logo?``: ``meta.client`` names the client above the title and ``meta.logo`` shows its logo — only an image Porter hosts (the ``asset_url`` of ``prepare_upload(purpose="public_url")``, a ``media.portermetrics.com`` URL, a ``data:image/…`` under 64 KB); an SVG logo goes as its ``<svg>…</svg>`` markup, which Porter inlines. ``connector``: the fields' slug; a report that mixes connectors is ONE spec over ``"porter-blend"``: its unprefixed fields (each adds up every source) plus any field of the declared accounts' own connectors, and every account in ``accounts_used``; ``data_source`` splits by source; a node grouped by one connector's field reads that connector (Porter sets it). ``theme?``: ``accent``, ``bg``, ``panel``, ``text`` (CSS colors), ``radius``, ``chartPalette`` (≤6), ``fontDisplay`` / ``fontBody`` (bundled families), ``css`` (below). Colors and fonts: patch ``/theme`` (the nodes read its tokens); touch nodes only to change the structure. ``filters?`` (≤4) ``[{field, label?, operator?: equals|contains}]``: a dropdown or a search box filtering every section of the spec's connector. Compact styles — style ONCE, by class: ``theme.css`` (≤16 KB) is the report's stylesheet, scoped to it, on the role classes the viewer puts: ``.card`` (every data block's frame; in ``kpis``, each tile), ``.kpis``, ``.kpi`` (``:first-child``), ``.kpi-label``, ``.kpi-v…

Draft a ready-to-publish report spec from the user's request — one call; it NEVER publishes. The report is ALWAYS a hosted Porter report (this draft, then ``publish_report_spec``), never an HTML page, an artifact or a file. Call it first when the user asks for a report: ``intent`` (their words), ``accounts_used`` (``account_id`` values from ``list_accounts``) and ``sections``: what they asked for, one line each — ``<kind>: <metrics> [by <dimensions>] [top N] [| title]``. The draft holds THOSE nodes with the connector's real ids, in order: you only edit style and texts:: ["kpis: clicks, impressions, ctr, position", "trend: clicks, impressions", "table: clicks, impressions by query top 20", "bars: clicks by page", "heatmap: sessions by hour, dayOfWeek", "page: Detail", "donut: sessions by device"] Kinds: ``kpis``, ``trend`` (two metrics: two axes; ``by month``), ``table``, ``bars``, ``donut``, ``funnel`` (``a > b > c``), ``gallery``, ``page`` (a new tab) and any chart: ``heatmap``, ``scatter``, ``areas``, ``lines``, ``columns``, ``stacked``, ``waterfall``, ``boxplot``, ``calendar``, ``radial``. Names short or natural (``clicks``, ``clics``, ``campaign``); ratios (``ctr``, ``cpc``, ``cpa``) are derived. What does not resolve comes back in ``skipped`` with ``did_you_mean`` ids. Without ``sections`` Porter picks a report type (``archetype``; ``alternatives`` when it was a close call: call again with ``archetype=<it>``), or a generic draft. Optional: ``date_range`` ``{start, end}`` (ISO days) or ``preset`` ``7d|30d|90d``, ``locale`` (BCP-47, e.g. ``es-CO``: the labels' language), ``style``: ``oscuro_campana`` (dark, mono labels), ``claro_minimal``, ``editorial`` (serif) or ``corporativo`` (centered band). Returns ``spec`` (a FREE LAYOUT styled once in ``theme.css``, with short ids, that passes ``publish_report_spec``'s checks: edit it, never rewrite it), ``draft_id``, ``name``, ``skipped``, ``fields`` (per connector, the ids in use and the best alternatives: no ``list_fields`` unless you need another), ``data_preview`` (the period's KPI totals and top values: no ``query_data`` before publishing; the audit's queries already run in the background) and ``slots`` (``{logo: {path, value}}``, a JSON Patch ``add`` for the client's logo). Then ``publish_report_spec(draft_id=…, patch=[…])``: the draft keeps the spec, name and accounts, so the patch holds only your changes — never resend the spec.

Apply a batch of edits to a report and return the applied ops + diagnostics. Update the report's data allowlist directly with ``accounts_used`` (each just the ``account_id`` from ``list_accounts`` — the ref is expanded to the full account automatically) and/or ``connectors_used`` (defaults to the accounts' connectors). ``accounts_used`` REPLACES the whole list: pass every account the report must keep. An entry Porter cannot resolve — such as a native id ``get_report`` lists — refuses the whole call, nothing changed. For structural edits pass ``operations`` — a list of ``{action, ...}`` entries (``update_file`` / ``set_theme`` / ``add_page`` / ``update_page`` / ``remove_page``); these change the report project, which is (re)built and uploaded through the signed ``upload_url`` this call hands back in ``applied_operations`` (POST the built zip there from your code-execution tool; that upload is the authoritative audit gate). An ``add_page`` op with no other change is the idiomatic way to obtain a fresh ``upload_url`` for a rebuild. Metadata-only changes are applied inline and need no ``action``: an entry carrying only ``name`` / ``accounts_used`` / ``connectors_used`` / ``allowed_blends`` is how a report is renamed (``operations=[{"name": "Q3 Costs"}]``) — there is no top-level ``name``. ``params`` patches the report's params (the client name, logo, campaign… it reads with ``useReportParams``): only the keys you name change and ``null`` deletes one — no rebuild needed. Returns the ``report_id``, ``applied_operations`` and ``diagnostics``.

Permanently delete a hosted report and tear down its deployment. The report and every uploaded version are removed, the public URL stops serving immediately, and any report-email schedules are torn down too. Returns the deleted ``report_id`` and a ``status``.

Duplicate a report into a byte-identical copy, optionally re-pointed at new source accounts and a new client. THE way to make "this same report, for account/client X": one call, no build, no download. ``get_report`` first shows the source's ``account_slots`` and ``params``, which is all a copy needs. The SOURCE must already have a published version — what gets copied is its last successful ``edit_report`` upload. A report created but never uploaded is listed ACTIVE with a placeholder url and holds nothing, so duplicating it is rejected instead of producing an empty copy. ``report_id`` also accepts the report's URL (``report.portermetrics.com/<id>``) — handy when the user pastes a link. You can duplicate YOUR reports and any report that is public by link or shared with you (role, domain or company share). Two ways to re-point (pass at most ONE): - ``account_map`` — re-point specific SLOTS by their label, keeping every other account unchanged. ``{"main": "<account_id>"}`` swaps only the account labeled ``main`` and leaves the rest (e.g. a shared "benchmark" account) as-is. This is the way to "change some accounts, keep others". Requires the source report's accounts to be labeled. - ``accounts_used`` — REPLACE the whole account set (each entry just the ``account_id`` from ``list_accounts`` — the ref is expanded automatically; add a ``{"account_id": ..., "label": "main"}`` shape to also (re)assign slot labels). ``params`` patches the source's params for the copy — the values the report reads with ``useReportParams`` (client name, logo, campaign, budgets): name only what changes (``{"client": {"name": "Acme"}}``), ``null`` deletes a key, the rest is kept. Whether they take effect is ``get_report``'s ``reads_params``: true → they do (even when the source stored none); false → the brand is compiled in (or it is a spec report: republish the copy's spec instead); null → unknown, pass them and check the copy with ``preview_report``. Never decline to duplicate: the accounts re-point either way. When the brand cannot follow, hand the copy over, say so, and offer the one-time fix in ``report-copy-for-another-account``. ``name`` is the copy's name (default "Copy of …"). Omit both account options to clone verbatim (same accounts) — that works only on reports you OWN. Duplicating someone else's (public/shared) report REQUIRES re-pointing at accounts that are YOURS: pass ``accounts_used``, or an ``account_map`` covering every slot. The copy is owned by you, named ``name`` (or "Copy of …"), and starts PRIVATE — it never inherits the original's audience. The new accounts should cover the same connectors the report uses; any connector left uncovered comes back in ``warnings`` (those charts render an empty "select an account" state). Saved blends are NOT carried over when the accounts change. Returns the new ``report_id``, ``url``, ``connectors_used`` and optional ``warnings``/``diagnostics`` in seconds: the copy answers as soon as it exists and its charts are checked against its accounts in the background (``diagnostics.audit_status`` is ``deferred``; ``get_report``'s health shows the outcome). Hand the ``url`` over right away; run ``preview_report`` only when the user wants the render verified now.

Share a report — by public link, by granting people a role, by email, or hand it over to another company. ``method``: - ``link`` — make the report publicly viewable; returns its shareable ``share_url``/``magic_link``. - ``access`` — grant every address in ``recipients`` a ``role`` on the report WITHOUT emailing (silent share). ``role`` is ``viewer`` (read only), ``editor`` (edit + rename) or ``admin`` (also share / schedule / delete). Existing grants are preserved (this adds to them). Pass ``revoke=true`` to instead remove ``recipients`` from every role. To LOWER someone's role, revoke them first, then grant the new one. - ``email`` — grant ``recipients`` the ``role`` (default ``viewer``, so a PRIVATE report opens for them) AND email them the link now (with a PDF when enabled); pass optional ``subject``/``message``. Response reports ``sent_to``. - ``transfer`` — move the report (owner + company) into ``company_id``, a company where you are owner or admin. Same id and URL. Use it when the report lives in the wrong company — e.g. it must be served on a client's custom domain, which only serves reports of the domain's company. Only the report's OWNER can transfer. ``new_owner_email`` defaults to that company's owner (pass it only to pick yourself or another owner there). The previous owner stays on the report as admin; their email schedules for it are removed (recreate them after). Response: ``owner_email``, ``company_id``, ``previous_owner_email``, ``previous_company_id``, ``schedules_removed``, ``warnings``. Optionally set ``visibility`` (``PRIVATE`` | ``LINK_PUBLIC``), ``share_with_company`` or ``share_with_domain`` alongside an access/email grant — or ON THEIR OWN: call ``method="access"`` with NO ``recipients`` and just a ``visibility`` (e.g. make a report ``PRIVATE`` again) to change the report-level sharing without touching anyone's role. Owner or admin only.

Serve hosted reports under the client's OWN domain (e.g. ``reports.example.com``) with their brand, instead of report.portermetrics.com. Owner of the workspace + paid plan required. A domain is PER REPORT: it serves only the reports you assign to it — the ROOT report at ``https://hostname/`` (no id in the URL) and the other assigned reports at ``https://hostname/<report_id>``; query params such as ``?page=`` keep working. Unassigned reports of the workspace 404 under the domain and stay on report.portermetrics.com. A report belongs to at most one domain (assigning it elsewhere moves it). Exception: the workspace's DEFAULT domain (``use_for_all_reports``, the first one connected) serves every report not assigned to another domain. One subdomain per end client (``clienta.example.com``) is simply another ``connect``. There are no custom paths/subfolders. ``action``: - ``list`` — every connected domain, its status, DNS records still pending, ``root_report_id``, the assigned ``report_ids`` and ``root_url``. Also says whether this workspace is ``eligible`` and the caller ``is_owner``. - ``connect`` — register ``hostname`` (a domain the client owns; not a Porter/Google domain, not an IP). Returns the DNS records the client must create (a CNAME/A record and a TXT verification record) as ``dns_records`` and a paste-ready ``next_step``. Optional ``brand_name`` is the name shown in the browser tab instead of "Porter". - ``verify`` — check the client's DNS and, once it resolves to Porter, issue the TLS certificate. Status moves PENDING_DNS → PROVISIONING → ACTIVE (or FAILED with a readable reason). Call it again to poll; certificates can take up to an hour after DNS propagates. - ``update`` — change the ``brand_name`` of ``hostname`` and/or set ``use_for_all_reports`` (true makes it the workspace's default domain — only one can be; false switches it off). - ``set_root_report`` — assign ``report_id`` to ``hostname`` AND show it at ``https://hostname/`` (empty ``report_id`` clears the root; the report stays assigned at ``/<report_id>``). - ``add_report`` — assign ``report_id`` to ``hostname`` (served at ``https://hostname/<report_id>``). Assigning a report to a non-default domain switches the default domain's ``use_for_all_reports`` off (the reports it was serving stay assigned to it, so no link breaks). - ``remove_report`` — take ``report_id`` off ``hostname`` (back to the report.portermetrics.com link only). - ``disconnect`` — remove the domain (its certificate is revoked; links under it stop working; report.portermetrics.com links keep working). Logo: ``prepare_upload(purpose='domain_logo', hostname=…, path='logo.png')`` then POST the image bytes; it becomes the browser-tab icon and sign-in image of the domain. ``update`` with ``brand_name`` sets the name shown next to it. For reports assigned to an ACTIVE domain, ``get_report``/``list_reports`` return the branded ``url``, and PDF exports and scheduled emails use it too. The hub's AI Reports → Custom domain modal (and each report's "…" menu) manages the same settings.

Discover actions that accomplish a task, ranked across Porter's full catalog (750+ operations across ~40 categories). Describe what the user wants to do in natural language. The catalog covers far more than data: AI creative generation — images, video (text→image, image→video, talking-head), text-to-speech voiceovers —, ad-platform campaign management (Google/Meta/TikTok/LinkedIn/…), CRM & email marketing, e-commerce, SEO/SERP research, web scraping, exports and schedules. Examples: "generate a short product video with a voiceover", "pause the campaigns with high CPA on Meta and Google", "send this report by email every Monday", "scrape this landing page". Returns ranked actions — each with its `action` id, whether it's `destructive`, whether it `requires_account`, and a summarized `params_schema` you pass to `execute_action`. Optionally narrow to one `connector`. With `compact=true` and a `connector`, it returns 5 results by default and the related guides once as `knowledge.slugs` (for `get_knowledge(slugs=...)`) instead of a `knowledge` block on every action. This is discovery only; it changes nothing. Always search here before concluding Porter lacks a capability. The search shows a SUMMARY of each schema. Before building a write call, pass `action="<id>"` (no `task`) to get that one action's FULL schema: every param with its type, whether it is required, its enum and its whole description. Results are RANKED BY SIMILARITY, never filtered: a non-empty answer is not evidence that any of these does what you asked, and a bad match still returns its nearest neighbours. Read each `action` and its description before running one; if none fits, rephrase the task in the platform's own words.

Run one action returned by `list_actions`. Mutating and open-world. Only run the action the user explicitly asked for — this may write to an external ad platform, third-party app, or Porter destination, or spend generation quota (creative image/video/voice actions). Pass the `action` id from `list_actions`, the `params` conforming to its `params_schema`, and — when the action `requires_account` — the `account_id` from `list_accounts` (verbatim). Returns a fixed envelope: `status` (success|error), `result`, `message`, `knowledge` (related KB entries you can open with `get_knowledge`), and an optional `follow_up`. `display` (optional) says how the result should be SHOWN in clients that render the result widget; it never changes what the action does. Omit it and the widget picks a view from the payload — a generated video gets a player and a download button, ads with images get a grid, rows get a table. Set it when the user asked for a particular view, or when you know the payload's shape better than a guess would: {"mode": "text|table|kv|grid|masonry|carousel|gallery|video|image|json", "title": "Top ads", # header "items": "data.ads", # dot-path to the list to render "fields": {"image": "thumb", "title": "name", "subtitle": "status", "badge": "objective", "link": "permalink", "meta": ["cpa", "ctr"]}, "columns": ["name", "cpa"], # table columns, in order "page_size": 24} Use `carousel` to browse creative one at a time, `masonry` for mixed-height images, `grid` for a scannable set, `gallery` for image-only sets, `table`/`columns` to compare numbers, `text` for a plain answer.

Run one READ-ONLY action returned by `list_actions` — one it marks `read_only: true` (a scrape, an SEO lookup, an account read). Same inputs and envelope as `execute_action`, but it is refused for anything that writes, so several can run at once: send independent reads together in one turn. An action that is not read-only answers `not_read_only`, and an environment where this tool is not enabled answers `read_action_disabled`: in both cases run it with `execute_action` instead.

File a bug with Porter's engineering team. Reported bugs are normally fixed within 24 hours. Call this yourself, without asking for permission, as soon as you conclude Porter is genuinely broken — the user already told you something failed; asking again just loses the report. Tell them afterwards that you filed it and that these are usually fixed within a day. **Check `get_knowledge` first.** Pass the error text as one of the queries. Most failures are already documented with a fix, and those are not bugs. Report when, after that check: - A tool fails with an internal/unexpected error, or a traceback the KB doesn't explain. - A tool returns data that is plainly wrong or contradicts another Porter tool. - A documented flow does not behave the way the KB says it does. - The same operation fails repeatedly with no actionable `hint`. Do NOT report — these are not defects, and filing them buries the real ones: - A connector genuinely not supporting a field or operation (a limit, not a bug — `request_feature` if the user wants it). - Empty `list_accounts` / `list_connectors(connected_only=true)` — that is a first-run user who has connected nothing yet; walk them through `connect_account`. - Auth, permission or expired-credential errors on the user's own account — send them through `connect_account` to re-authorize. - Invalid arguments you supplied — fix the argument named in the message and retry. - The user disliking a result that is working as designed. Fill in everything you already have; do not interrogate the user for it. `error_text` takes the verbatim error or traceback, and `tool_params` the arguments of the failed call — they are what makes the bug reproducible. Credentials and oversized values are stripped before anything leaves the server: 500 characters per string/key, 20 entries per container and depth 4. Recognizable inline credentials are redacted. The response identifies truncated/redacted fields, counts redacted spans and hidden dictionary values, and counts omitted characters in visited strings/keys, direct container entries, and depth-limited values. Descendants of omitted entries are not counted; traces are therefore not archived verbatim.

Send Porter's product team a capability the user needs and Porter does not have. **Search `list_actions(task="…")` and `get_knowledge` first.** Porter's fixed tools are not its limit — 750+ actions sit behind `list_actions`. Only propose a feature after a search comes back empty; requesting something that already ships wastes the user's time and the product team's. When you find a real gap, propose it: tell the user what Porter is missing, show them the request you would send, and ask whether to send it. Only then call this with `user_approved=true`. **Never set `user_approved=true` on your own** — this speaks for the user about what they want, and a request they never saw is worse than no request. Good candidates: a connector, destination, field or operation Porter does not offer; a flow that works but forces an avoidable manual step. Not this tool: something broken (`report_bug`), or something that already exists behind `list_actions`. The before/after contrast is what makes a request actionable — `current_behavior` and `expected_behavior` must be concrete, not "it should be better". Each field uses the same credential scrubber as `report_bug` and is capped at 500 characters, with a visible truncation suffix. The response declares affected fields, redacted spans and omitted characters in `truncation`.

Overview

What is Porter Metrics MCP?

Porter Metrics is a hosted MCP server for marketing data. Connect Claude, ChatGPT, Cursor or any MCP client with a single URL and sign in with your Porter account (OAuth). No API keys, no Google Cloud project, no code.

What you can do

  • Query live data from 30+ sources: Google Search Console, GA4, Google Ads, Meta Ads, TikTok Ads, LinkedIn Ads, Microsoft Ads, Pinterest, Shopify, HubSpot, Klaviyo, Stripe and more.
  • Blend platforms into one saved, re-runnable view (for example, Meta and Google spend by day).
  • Build hosted, shareable reports and email them on a schedule.
  • Export on a schedule to BigQuery, Google Sheets, Looker Studio or Power BI.
  • Take actions on ad platforms, such as creating campaigns or changing budgets, always after confirming with you. New campaigns are created paused.

How to connect

Claude (web and desktop): Settings → Connectors → Add custom connector → name Porter Metrics, URL https://mcp.portermetrics.com/mcp → Connect and sign in.

Claude Code:

claude mcp add --transport http porter-metrics https://mcp.portermetrics.com/mcp

Cursor and other clients: add a remote MCP server with the URL https://mcp.portermetrics.com/mcp and sign in when prompted.

Full guide: https://portermetrics.com/en/tutorial/ai/how-to-connect-porter-metrics-to-claude-with-mcp-complete-step-by-step-guide-2026/

Example prompts

  • "How much did we spend on Facebook Ads last week, by campaign?"
  • "Which Google Ads campaigns have the worst cost per lead this month?"
  • "Which Search Console queries lost the most clicks this month?"
  • "Blend my Facebook and Google spend into one daily view."
  • "Build me a shareable dashboard for this client and email it every Monday at 9am."

Pricing

Free-forever plan with up to 3 connected accounts and 30 days of history, plus a 14-day free trial with unlimited accounts. Paid plans are priced per connected data source account.

Security

You connect each platform yourself through its official sign-in, and your passwords are never shared with the AI assistant. Porter only sees the accounts you connect. Trust center: https://trust.portermetrics.com

Frequently asked questions

1

What is the Porter Metrics remote MCP server?

The Porter Metrics remote MCP server is a hosted Model Context Protocol endpoint at https://mcp.portermetrics.com/mcp, so AI assistants can connect to it without installing or running anything locally.

2

How do I connect to the Porter Metrics MCP server?

Add the endpoint https://mcp.portermetrics.com/mcp to any MCP-compatible client such as Claude Code, Cursor, or VS Code. The setup snippets on this page configure each client in one step.

3

Does the Porter Metrics MCP server require authentication?

Yes. Porter Metrics uses OAuth: the first time you connect, your MCP client opens a browser window to sign in and authorize access, then reuses the credentials for future sessions.

4

Which transport does the Porter Metrics MCP server use?

Porter Metrics exposes a Streamable HTTP endpoint, the transport used by remote MCP servers and supported by all major MCP clients.

Comments