Skip to main content
Glama
README.md
# Marketo MCP Server

An MCP (Model Context Protocol) server that exposes Adobe Marketo REST API operations as tools. Built with [FastMCP](https://github.com/jlowin/fastmcp), it allows AI assistants and MCP clients to interact with your Marketo instance.

This repo contains two generations of server, all backed by a **single API library** — [marketo_functions.py](marketo_functions.py), 325 functions covering the complete Marketo REST API surface (leads, schema/custom fields, companies, opportunities + roles, sales persons, custom objects + types, named accounts, program members, smart campaigns/lists, snippets, segmentation, emails + templates + files, landing pages + templates, redirect rules, forms, folders, tokens, bulk import/export for leads/activities/program members/custom objects, usage stats, Asset v2 (the new Email Designer), and user management). Every MCP tool in every server is a thin FastMCP wrapper over the corresponding function. Intentionally excluded: the Data Ingestion API (separate host, paid add-on SKU) and the deprecated `/rest/v1/campaigns.json` endpoints.

- **`mcp_server_blended.py` (recommended if you have native MCP access)** — proxies [Adobe's native Marketo MCP server](https://experienceleague.adobe.com/en/docs/marketo-developer/marketo/mcp-server) for its ~120 tools and adds 255 `custom_*` tools for everything it lacks. See [Blended Server](#blended-server-recommended-if-you-have-native-mcp-access).
- **`mcp_server.py` / `mcp_server_auth.py`** — standalone servers exposing the full surface (297 tools: 42 original + 255 expanded) with credentials in `.env`, for teams **without** native Marketo MCP access. See [Legacy Servers](#legacy-servers).

## Blended Server (recommended if you have native MCP access)

`mcp_server_blended.py` gives you a single MCP endpoint that combines:

1. **Native tools** — every tool from Adobe's native Marketo MCP server (`https://marketo-mcp.adobe.io/mcp`), mirrored live and called via proxy. These keep their exact Adobe names (`browse_forms`, `get_leads_by_filter`, ...).
2. **Custom tools** — capabilities the native server doesn't offer, called directly against the Marketo REST API from this server. These are all prefixed **`custom_`** and their descriptions start with `[CUSTOM]`, so any tool-call log or accept/reject prompt tells you exactly which path is being used.

On any name conflict, the local `custom_*` tools win (and the prefix makes conflicts impossible today).

### Authentication

No credentials live on this server or in `.env` — clients send three headers on **every** request:

| Header | Value |
|---|---|
| `x-client-id` | REST API client ID (Admin > LaunchPoint) |
| `x-client-secret` | REST API client secret |
| `x-workspace-id` | Munchkin ID, e.g. `123-ABC-456` (Admin > Munchkin) |

The names are generic on purpose. Claude Desktop's connector form only accepts header names from its own dropdown — anything else is rejected — so all three are picked from that list. That is why the Munchkin ID travels as `x-workspace-id`: it is the name the form allows, and it carries a Munchkin ID like any other instance identifier.

For proxied tools the credentials are re-sent to Adobe under the `X-Marketo-*` header names its native server expects; for custom tools they are exchanged for a Marketo REST OAuth token (cached per credential set until near expiry). That translation happens inside [credentials.py](credentials.py) — this server itself accepts only the three names above. The REST base URL is always derived from the Munchkin ID (`https://<munchkin-id>.mktorest.com`); there is no override header.

> **Note:** Adobe's native MCP server is in limited availability — your Munchkin ID must be allowlisted by Adobe. Until then, the proxied tools will fail upstream, but all `custom_*` tools work with any valid REST API credentials.

### Run

```bash
python mcp_server_blended.py     # serves http://0.0.0.0:8000/mcp
```

The listen port comes from the `PORT` env var when set (Replit and most PaaS inject this), defaulting to 8000.

If the native Adobe server can't be reached (missing headers, Munchkin ID not yet allowlisted, upstream outage), `tools/list` degrades gracefully to the `custom_*` tools instead of failing — so clients that import the tool list at session start (e.g. OpenAI agents) still connect.

Example `mcp.json` (VS Code / Cursor / any HTTP-capable MCP client):

```json
{
  "servers": {
    "marketo-blended": {
      "type": "http",
      "url": "http://localhost:8000/mcp",
      "headers": {
        "x-client-id": "YOUR-CLIENT-ID",
        "x-client-secret": "YOUR-CLIENT-SECRET",
        "x-workspace-id": "YOUR-MUNCHKIN-ID"
      }
    }
  }
}
```

#### Connecting Claude Desktop or Claude Web

The blended server is a remote MCP server, so Claude connects to it directly — no `mcp-remote` bridge, no `claude_desktop_config.json` edit, no local Python. Setup is a URL and three headers:

1. Open Claude Desktop (or Claude on the web).
2. Go to **Settings > Connectors > Add custom connector**.
3. Give it a name (e.g. `Marketo`) and paste the server URL, ending in `/mcp` — for example `https://your-deployment.example.com/mcp`.
4. Continue to the second page. Under **request headers**, add three — all three names are in the dropdown, so pick each one rather than typing it (**Custom header…** names are rejected unless Anthropic has allowlisted them):

   | Header name | Where to find it | How to add it |
   |---|---|---|
   | `x-client-id` | Marketo REST client ID (Admin > LaunchPoint) | Pick from the dropdown |
   | `x-client-secret` | Marketo REST client secret | Pick from the dropdown |
   | `x-workspace-id` | Munchkin ID, e.g. `123-ABC-456` (Admin > Munchkin) | Pick from the dropdown |

Claude stores the values securely and sends them with every request. Each person connects with their own Marketo credentials, so tool calls run as that user's LaunchPoint service and respect its API role permissions.

The server must be reachable over HTTPS from Claude's servers — a `localhost` URL will not work. To run the server on your own machine instead, bridge it into Claude Desktop with `mcp-remote`: [marketo-mcp-claude-desktop-setup.md](marketo-mcp-claude-desktop-setup.md) is that runbook for Adobe's native server, and the same steps work here with this server's URL and the `x-client-id` / `x-client-secret` / `x-workspace-id` header names.

> **Security:** credentials transit as HTTP headers — only expose this server over TLS (or keep it on localhost / a trusted network), and never log header values.

### Custom tools (255)

Every Marketo REST operation the native MCP lacks, as `custom_*` tools grouped by domain (definitions in [custom_tools.py](custom_tools.py) and [marketo_rest/](marketo_rest/)):

| Group | Coverage |
|---|---|
| Leads & schema | sync (incl. `createDuplicate`), get/delete, merge, push, submit form, associate, partitions, program status, list/program/campaign membership, changes, custom lead fields |
| Activities | deleted leads, add custom activities, full custom activity type lifecycle |
| CRM objects | companies, opportunities + roles, sales persons, custom objects + full type/schema lifecycle, named accounts + lists (ABM) |
| Program members | status/data sync, delete, custom PM fields |
| Bulk | lead import + failures/warnings; activity/program-member/custom-object exports (create/enqueue/status/file/cancel/list); PM/CO imports; lead export list/cancel |
| Emails & templates (Editor 1.0/2.0, Asset v1) | metadata/clone/delete/unapprove/discard, headers, modules, dynamic content, full content, variables, full email template lifecycle, files upload/replace |
| Landing pages | full LP lifecycle + content sections + dynamic content + variables, LP template lifecycle, redirect rules, domains, segmentation reads |
| Forms & assets | form delete/discard/field deletes/submit button/thank-you pages, folder delete, smart campaign clone, smart list/snippet deletes |
| Asset v2 (new Email Designer / "Editor 3.0") | email/template/fragment create/get/update/delete/clone/state transition/used-by |
| User management | users, roles, workspaces, invites |
| Stats | daily/weekly usage and errors |

### Which email tools for which editor?

Marketo has three generations of email authoring, served by two APIs that do
**not** overlap. Picking the wrong family is the most common failure here, so
the naming carries the answer: **`_legacy` = Email Editor 1.0/2.0, unsuffixed =
new Email Designer.** Every email tool's description also opens with a bracket
tag naming its editor — including Adobe's native tools, which the blended
server retags on the way through.

| Emails built in | API | Tools |
|---|---|---|
| Email Editor 1.0 / 2.0 — classic, section- and module-based | Asset **v1** | Native `legacy_email_*` (create, get by id/name, get/update content, approve) **+** the `custom_*_email_legacy` / `custom_*_email_template_legacy` tools |
| New Email Designer — informally "Editor 3.0", internally **"Acrite"**; block-based, built on themes and fragments | Asset **v2** | The **unsuffixed** `custom_*_email` / `custom_*_email_template` tools, plus `custom_*_fragment`. The native MCP has **no** Designer tools |

```
custom_update_email_legacy    Editor 1.0/2.0     custom_update_email    Designer 3.0
custom_clone_email_legacy     Editor 1.0/2.0     custom_clone_email     Designer 3.0
legacy_email_update_content   Editor 1.0/2.0  (native, name kept, description retagged)
```

Editor 2.0 coverage is complete across the two halves: the native server
supplies create / approve / content read + write, and the custom tools supply
update, clone, delete, unapprove, discard draft, headers, the five module
operations, dynamic content, variables, full-HTML replace, and the whole
classic email-template domain.

**A note on Adobe's naming.** Adobe calls its classic tools `legacy_email_*`
but never says legacy *to what*, and "Legacy" reads as deprecated. It is not:
for a classic email those are the correct tools, and `legacy_email_create` is
the only way to create one here. `marketo_rest/native_tags.py` prepends an
editor tag to their descriptions as they pass through the proxy — Adobe's own
wording is left intact underneath and their names are unchanged, since those
are Adobe's public interface. Native email tools Adobe adds later arrive
untagged until they are added to `TAGS`.

**Reading the errors:**

| Error | Meaning |
|---|---|
| `709` *Cannot access content for Acrite Email asset* | A `_legacy` (or native `legacy_email_*`) tool was pointed at a new Email Designer email. Drop the `_legacy` suffix. |
| `603` *Access denied* from an unsuffixed (Designer) tool | A **permissions/provisioning** problem, not a wrong-asset problem: the instance lacks the new Email Designer, or the API role lacks Asset v2 access. It affects every email equally — check by calling `custom_get_email_by_id` on any other email. |

Two Designer limits worth knowing: Asset v2 has no browse-emails endpoint
(templates and fragments do have one), and its update call takes a whole `data`
block rather than addressable sections — Designer content is not
section-editable the way Editor 2.0 content is.

**Reusable content: snippets vs fragments.** The two generations use different
mechanisms, and all 14 snippet tools plus all 8 fragment tools are tagged
accordingly.

| | Used by | Tools |
|---|---|---|
| **Snippets** | Email Editor 1.0/2.0 emails **and** landing pages | 11 native `*_snippet` + `custom_delete_snippet`, `custom_discard_snippet_draft`, `custom_unapprove_snippet` |
| **Fragments** | New Email Designer emails only | 8 `custom_*_fragment` |

No snippet tool is email- or landing-page-specific: none of them names a
consuming asset, and a snippet lives in its own Design Studio folder
(`folderType: "Snippet"`). So they carry an ASSET-LEVEL tag rather than an
editor tag — it says the email half of their reach stops at Editor 2.0, and
points at fragments for the Designer. The fragment tools point back the other
way.

### Test

`test_blended_server.py` exercises the blended server end-to-end against a
real Marketo sandbox. Run it with no arguments and pick a mode from the menu:

```
$ python test_blended_server.py

Select what to run:
    1. Read-only tests (safe, no modifications)
    2. Write-only tests (create, clone, update, delete — temporary test assets, auto-cleaned)
    3. Full tests (read-only + write operations)
    4. Bulk-export tests (tiny jobs)
    5. Bulk-import tests (tiny jobs)
    6. Live mode (quick single smoke call against a running server)
```

Credentials come from the environment or a `.env.sandbox` file
(`MARKETO_CLIENT_ID` / `MARKETO_CLIENT_SECRET` / `MARKETO_MUNCHKIN_ID`); modes
1–5 self-start the blended server. Every menu option makes **real** Marketo
calls. The same modes are available non-interactively as CLI subcommands
(`readonly`, `write`, `full`, `live`) plus `full --group bulk-export|bulk-import`.

**How steps are ordered (applies to every mode).** The suite is a dependency
DAG, not a flat list: a thing is always *discovered or created* before it is
*used*. Browse calls run first and stash the first result's id/name; later
get-by-id / get-by-name / content / update / delete steps read those stashed
values. Any step whose prerequisite wasn't found is **SKIP**ped (not failed),
so the run degrades gracefully on instances missing a feature or some data.

**SKIP vs FAIL.** A SKIP means an expected, environment-driven non-result:
a missing prerequisite, a permission the API role lacks, a feature not
provisioned (Asset v2 / new Email Designer, ABM named accounts, User Management), or a
documented state error. A FAIL is a real problem (exception or unexpected
error code). A clean run is `0 FAIL`, and the full run also asserts
`0 UNCOVERED` (every one of the 256 custom tools was exercised).

> `full --dry-run` prints the planned steps and the coverage check **without
> calling Marketo** — a developer convenience only; it is never reached from
> the menu (every menu option runs for real).

**Reducing SKIPs.** A typical sandbox run skips ~70 steps. Most are removable
by changing the instance/role rather than the code:

| SKIP reason | How to make it PASS |
|---|---|
| `permission(603)` on field-schema tools (lead / program-member / company / opportunity / named-account fields) | Grant the API user's role the **Read-Write Schema Standard/Custom Field** permissions (Admin → Users & Roles) |
| User Management tools (`list_users`, `invite_user`, …) + the safety-gated user mutations | Grant the role the **Access User Management** permission |
| Asset v2 / new Email Designer (23 tools) | Enable the **new Email Designer** on the instance and grant the role Asset-v2 access. *(The requests already send the required `x-app-type` header — without both, every call returns `603 Access denied`, on every email.)* |
| Redirect-rule tools (`no LP domains configured`) | Configure a **landing-page domain (CNAME)** on the instance |
| `campaign-not-activatable`, `email has no modules`, `no dynamic content`, `freeForm LP has no variables`, pre-existing form fields, `not-an-email-program` | **Inherent** — the operation legitimately doesn't apply to the minimal test fixtures. Forcing them would mean building elaborate trigger campaigns / modular emails / segmentations for little added confidence, so the suite skips them by design. |

Reordering steps does **not** help — these are permission/feature/state
conditions, not ordering problems. The two big levers are entirely in your
hands: grant the API role the schema + user-management permissions, and (if you
use it) enable the new Email Designer.

#### 1. Read-only tests
A mutation-free discovery pass: it browses each asset type (folders, lists,
programs, smart campaigns/lists, snippets, forms, landing pages + templates,
email templates, emails, files, segmentations, custom objects, CRM objects)
and reads the first result with the matching get/by-name/content tools, plus
the stats and describe tools. A guard *refuses to execute any write step*, so
it is safe to point at any instance.

*Why `get_list_members` is called several times:* the lead-centric read tools
(`custom_get_lead_by_id`, `get_lead_changes`, membership tools, …) need a real
lead id, and the only way to get one **without creating anything** is to read
an existing populated static list. Lists are often empty, so the pass walks
the first several lists calling `get_list_members` and **stops the moment one
returns members** (remaining probes SKIP with *"lead already found"*). If no
list has members it falls back to pulling a lead from a program. That's why
you'll see a handful of `get_list_members` calls, some PASS (empty or worked
lists) and some SKIP (after a lead was found).

*Expected SKIPs:* `custom_get_lead_fields` / `custom_get_lead_field_by_name`
(need the schema permission on the API role) and `custom_list_workspaces`
(needs the "Access User Management" permission). Grant the role those
permissions in Marketo **Admin → Users & Roles** to turn them into PASS.

#### 2. Write-only tests
The create / update / clone / delete lifecycle. It builds a small set of
throwaway assets, exercises the write tools against them, and **deletes
everything at the end**. You're first prompted for a run *suffix* appended to
the asset names (e.g. `MCPTEST_FULL_LP_CLONE_<suffix>`) so the run's assets
are uniquely named — this avoids name collisions with Marketo (which rejects
duplicate names) or with a previous run that crashed before cleanup, and lets
cleanup target exactly this run's assets. **Hit Enter** to use an
auto-generated timestamp; the suffix only affects asset names, never what's
tested.

*Why it runs ~330 steps* — far more than the number of write tools. Three
things multiply the count:
1. **Dependencies.** Most write tools can't run in isolation — they need a
   parent asset to act on. Testing `custom_update_email_legacy` first needs an email,
   which needs a template, which needs a folder; testing
   `custom_delete_program_members` needs a program with a member, which needs a
   lead and the instance's required tags. So each write is preceded by the
   `create_*` (often native) calls that build its target.
2. **Full lifecycle per asset.** An asset is created → read back → updated →
   (un)approved/cloned → finally deleted, so one asset touches many tools.
   Creates appear near the top, the matching deletes are batched in a teardown
   phase at the end (that's the run of `custom_delete_*` you see last).
3. **Full coverage.** Write-only exercises **every** write-capable custom tool
   plus the native infrastructure tools needed to set up their targets — that's
   what makes the full run assert `0 UNCOVERED`.

So the step count is "every write tool × (its prerequisites + its teardown)",
not redundancy — nothing is tested twice.

#### 3. Full tests
Read-only pass **+** write lifecycle, and the only mode that asserts full
coverage (256 custom tools, `0 UNCOVERED`). Note: **write tools run exactly
once** (only in the lifecycle), but a subset of **read tools run twice** — once
in the read-only pass against pre-existing assets and once in the lifecycle
against the freshly-created ones. That read duplication is intentional (two
data shapes) and cheap; no write is ever repeated.

#### 4. Bulk-export tests
Runs the bulk-export tools **plus their minimal prerequisites**. A bulk export
is only meaningful with data to extract, so the group first *seeds a tiny,
known dataset and discovers the metadata* needed to form valid requests,
**then** runs the exports, **then** cleans up. The pre-export steps are:

- `browse_folders` → `create_folder` — a container for the test assets
- `custom_sync_leads` — the leads the lead/activity/program-member exports extract
- `browse_tag_types` → `get_tag_type_by_name` — discover a required program tag (this instance requires tags)
- `create_program` + `custom_change_lead_program_status` — a program with a member, so the PM export has rows
- `sync_custom_object_type` → `add_fields` → `approve` → `sync_custom_objects` — a custom object with records to export
- `get_activity_types` — to scope the activity export to specific type ids
- `get_program_member_fields` — to pick valid fields for the PM export

Each export then runs `create → enqueue → poll status → fetch file`, plus a
second job to exercise `cancel`, plus `list`. The ~12s steps are the status
polling. **Jobs are deliberately tiny** — exports use a few-minute time window
over the run's own records, and the activity export is additionally scoped by
`activity_type_ids` so it can never pull a large file regardless of instance
traffic (keeping well clear of Marketo's 500 MB/day extract quota). Seeding a
known dataset (rather than exporting whatever already exists) is what makes the
test both small and deterministic.

#### 5. Bulk-import tests
The bulk import lifecycle (leads, custom objects, program members) using
**2–3 row CSVs** so each job is a few KB and finishes in seconds, with the same
seed-then-import-then-cleanup shape and status/failures/warnings checks.

#### 6. Live mode
A quick single smoke call against an **already-running** blended server (it
does not self-start one) — lists the tools and calls one custom tool. Use it to
confirm a deployment is reachable and authenticating, not for coverage.

### Blended server files

```
mcp_server_blended.py   # entry point — proxy wiring
credentials.py          # header extraction + per-credential OAuth token cache
marketo_functions.py    # THE API library — all 325 REST functions (shared by every server)
custom_tools.py         # custom_* tool wrappers (+ marketo_rest/ domain modules)
marketo_rest/bridge.py  # creds resolution + token retry between tools and the library
legacy_api.py           # env-credential provider for the legacy servers
test_blended_server.py  # tests (read-only / write / full / bulk / live modes)
```

---

## Legacy Servers

## Prerequisites

- Python 3.10+
- A Marketo instance with API access (client ID, client secret, and REST API base URL)

## Setup

### 1. Clone the repository

```bash
git clone <repo-url>
cd MarketoMCP
```

### 2. Create a virtual environment and install dependencies

```bash
python -m venv venv
source venv/bin/activate  # macOS/Linux
# venv\Scripts\activate   # Windows

pip install -r requirements.txt
```

### 3. Configure environment variables

Copy the template and fill in your Marketo API credentials:

```bash
cp .env_template .env
```

Edit `.env` with your values:

```
MARKETO_CLIENT_ID="your-client-id"
MARKETO_CLIENT_SECRET="your-client-secret"
MARKETO_BASE_URL="https://your-instance.mktorest.com"
MCP_API_KEY="your-secret-api-key"
```

You can find these in **Marketo Admin > LaunchPoint** (for client ID/secret) and **Admin > Web Services** (for the REST API base URL).

### 4. Start the server

There are two server scripts to choose from:

#### `mcp_server.py` — No authentication

```bash
python mcp_server.py
```

This server has no auth layer at all, so it should only be exposed on trusted networks or behind a reverse proxy that handles authentication.

#### `mcp_server_auth.py` — Bearer token authentication (for OpenAI and other clients)

```bash
python mcp_server_auth.py
```

Use this when connecting from **OpenAI** or any MCP client that supports bearer token authentication. Clients must include the `MCP_API_KEY` value from your `.env` file in every request:

```
Authorization: Bearer <your-MCP_API_KEY-value>
```

Add the key to your `.env`:

```
MCP_API_KEY="your-secret-api-key"
```

> **Why two servers?** `mcp_server_auth.py` gates the endpoint with one shared key for every caller; `mcp_server.py` leaves gating to whatever sits in front of it. Both read one instance's credentials from `.env`, so everyone connected shares a single Marketo login — unlike the blended server, where each client sends its own. Claude Desktop can connect to either via custom-connector request headers.

Both servers start on `http://0.0.0.0:8000` using the Streamable HTTP transport. The MCP endpoint is available at `http://localhost:8000/mcp`.

## Available Tools

### Activities

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_activity_types` | Get all available activity types | — |
| `get_lead_activities` | Get recent activities for a lead by ID | `lead_id`, `activity_type_ids?`, `days_back?` (default: 7) |
| `get_lead_activities_by_email` | Get recent activities for a lead by email | `email`, `activity_type_ids?`, `days_back?` (default: 7) |
| `get_lead_changes` | Get data value changes for a lead | `lead_id`, `fields?`, `days_back?` (default: 7) |

### Leads

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_lead_by_email` | Look up a lead by email address | `email` |
| `describe_leads` | Get lead field metadata and schema | — |

### Emails

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_email_by_id` | Get an email asset by ID | `email_id` |
| `get_email_by_name` | Get an email asset by name | `name`, `folder_id?` |
| `browse_emails` | Browse email assets with filtering | `max_return?`, `offset?`, `status?`, `folder_id?`, `earliest_updated_at?`, `latest_updated_at?` |
| `get_email_content` | Get content sections of an email | `email_id`, `status?` |
| `get_email_cc_fields` | Get fields enabled for Email CC | — |
| `preview_email` | Get a live preview of an email | `email_id`, `status?`, `content_type?`, `lead_id?` |

### Channels

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_channels` | Get available program channels | `max_return?`, `offset?` |

### Folders

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_folder_by_name` | Get a folder by name | `name` |
| `browse_folders` | Browse folders | `max_return?`, `offset?`, `folder_type?` |

### Smart Campaigns

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_smart_campaign_by_id` | Get a smart campaign by ID | `campaign_id` |
| `get_smart_campaign_by_name` | Get a smart campaign by name | `name` |
| `browse_smart_campaigns` | Browse smart campaigns with filtering | `max_return?`, `offset?`, `is_active?`, `folder_id?`, `earliest_updated_at?`, `latest_updated_at?` |
| `create_smart_campaign` | Create a new smart campaign | `name`, `folder_id`, `description?` |
| `update_smart_campaign` | Update an existing smart campaign | `campaign_id`, `name?`, `description?`, `folder_id?` |
| `clone_smart_campaign` | Clone a smart campaign | `campaign_id`, `name`, `folder_id`, `description?` |
| `schedule_batch_campaign` | Schedule a batch campaign to run | `campaign_id`, `run_at?`, `tokens?`, `clone_to_program?` |
| `request_campaign` | Trigger a campaign for specific leads | `campaign_id`, `lead_ids?`, `tokens?` |
| `activate_smart_campaign` | Activate a smart campaign | `campaign_id` |
| `deactivate_smart_campaign` | Deactivate a smart campaign | `campaign_id` |
| `delete_smart_campaign` | Delete a smart campaign | `campaign_id` |

### Programs

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_program_by_id` | Get a program by ID | `program_id` |
| `get_program_by_name` | Get a program by name | `name`, `include_tags?`, `include_costs?` |
| `browse_programs` | Browse programs with filtering | `max_return?`, `offset?`, `status?`, `earliest_updated_at?`, `latest_updated_at?` |
| `create_program` | Create a new program | `name`, `folder_id`, `program_type`, `channel`, `description?`, `costs?`, `tags?`, `start_date?`, `end_date?` |
| `update_program` | Update an existing program | `program_id`, `name?`, `description?`, `costs?`, `costs_destructive_update?`, `tags?`, `start_date?`, `end_date?` |
| `clone_program` | Clone a program | `program_id`, `name`, `folder_id`, `description?` |
| `approve_email_program` | Approve an email program | `program_id` |
| `unapprove_email_program` | Unapprove an email program | `program_id` |
| `delete_program` | Delete a program and all child contents | `program_id` |

### Program Members

| Tool | Description | Parameters |
|------|-------------|------------|
| `describe_program_members` | Get program member field metadata | — |
| `query_program_members` | Query program members with filtering | `program_id`, `filter_type`, `filter_values`, `fields?`, `start_at?`, `end_at?` |

### Tokens

| Tool | Description | Parameters |
|------|-------------|------------|
| `get_tokens_by_folder` | Get tokens for a folder | `folder_id`, `folder_type?` |
| `create_token` | Create a new token | `folder_id`, `name`, `token_type`, `value`, `folder_type?` |
| `update_token` | Update an existing token | `folder_id`, `name`, `token_type`, `value`, `folder_type?` |
| `delete_token` | Delete a token | `folder_id`, `name`, `token_type`, `folder_type?` |

> Parameters marked with `?` are optional.

## Testing

Two test suites are provided — one tests the Marketo functions directly, the other tests through the MCP server protocol.

### Test the underlying functions directly

This calls `marketo_functions.py` without the MCP layer. Requires a valid `.env` configuration.

```bash
python test_marketo_functions.py
```

### Test via the MCP server

This connects to the running MCP server as an MCP client. Start the server first, then run the tests in a separate terminal.

```bash
# Terminal 1 - start the server
python mcp_server.py

# Terminal 2 - run the tests
python test_mcp_server.py
```

### Test modes

Both scripts support interactive modes (1 read-only / 2 write / 3 full) and a
non-interactive `--auto` mode that drives the full surface end-to-end:

```bash
# Functions: every one of the 325 functions, dependency-ordered, MCPTEST_LEG_*
# assets created and cleaned up. Reads .env or .env.sandbox.
python test_marketo_functions.py --auto

# Tools: every one of the 297 tools — self-starts mcp_server.py and drives it
# as an MCP client.
python test_mcp_server.py --auto

# Bulk groups run standalone with only their minimal prerequisites (tiny jobs):
python test_marketo_functions.py --group bulk-export
python test_marketo_functions.py --group bulk-import
python test_mcp_server.py --group bulk-export
python test_mcp_server.py --group bulk-import
```

`--auto` prints a coverage report (every function/tool exercised) and a
PASS/FAIL/SKIP summary, and exits non-zero on any FAIL or uncovered item.
SKIPs are for un-provisioned features (Asset v2, ABM, user management,
field-schema permission) or documented state-dependent operations.

Notable test chains: leads are created (`createOnly`), a duplicate is made
(`createDuplicate`), then merged — covering create, duplicate, and merge in
one flow; required program tags are discovered from the instance's tag types
before creating a program.

### Test configuration

Interactive runs save inputs (emails, folder IDs, names) to `test_config.json`
so you don't re-enter them. `--auto` runs need no config — they discover
targets from the instance. Delete `test_config.json` to reset interactive inputs.

## Project Structure

```
MarketoMCP/
├── marketo_functions.py         # THE API library — all 325 Marketo REST functions
├── mcp_server_blended.py        # Blended server: native-MCP proxy + custom_* tools (header auth)
├── mcp_server.py                # Legacy server — no auth layer
├── mcp_server_auth.py           # Legacy server — bearer token auth (OpenAI etc.)
├── custom_tools.py              # custom_* tool wrappers (registers the marketo_rest modules)
├── marketo_rest/                # Domain tool modules + bridge.py (creds/token plumbing)
├── credentials.py               # Blended-server header creds + OAuth token cache
├── legacy_api.py                # Legacy-server env-credential provider
├── test_blended_server.py       # Blended tests (read-only / write / full / bulk / live)
├── test_mcp_server.py           # Legacy MCP protocol test suite (--auto / --group)
├── test_marketo_functions.py    # Direct function test suite (--auto / --group)
├── requirements.txt             # Python dependencies (fastmcp>=3,<4)
├── .env_template                # Environment variable template
└── .env                         # Your credentials (not committed)
```