I gave my Actor a delivery address: shipping results with MCP connectors Apify shipped Model Context Protocol (MCP) connectors in June 2026, letting Actors call external services mid-run through an Apify-managed proxy so credentials never reach the Actor. Developer Steven Carleton documented the build of version 0.2.2 of his Florida license records Actor, which uses the official MCP TypeScript SDK 1.30.0 and six live runs to cap digests at 25 records, diff runs against previous ones, and link each number back to its dataset. Carleton rejected the documentation's output-writing Actor pattern of declaring multiple tool-prefix rules because real services name tools inconsistently, such as Slack's send_message, Notion's notion-create-pages, Google Sheets' append_rows, and Linear's create_issue. Steven Carleton https://apify.com/cblu as part of Write for Apify https://apify.com/resources/write-for-apify - a program for developers sharing original articles about what they've built with Apify. My Florida license Actor https://apify.com/cblu/florida-license-records-scraper streams the official license extract files published by Florida's Department of Business and Professional Regulation DBPR , filters them, and charges per record returned. These are public records the state publishes for precisely this purpose, and the Actor fetches each file once per run at ordinary download rates. It works. And then it stops at the dataset, which is where nobody wants the answer to live. I know what my users do next, because it is what I do next. Download the CSV file. Open it. Copy the 20 rows that matter into a spreadsheet somebody else can see. That last step is the whole point of the run. It happens by hand, after the fact, in a different application. Apify shipped Model Context Protocol MCP connectors https://docs.apify.com/platform/integrations/mcp-connectors in June 2026, and they closed that gap. This article is the build log: what I declared, what I wrote, what I tried first that was wrong, and the six real runs that proved it. Everything below is from build 0.2.2 of a live Actor. What MCP connectors are and why not a webhook A connector is a new kind of Actor input. The user authorizes a service once in their account, picks it when they run the Actor, and the Actor talks to that service mid-run through an Apify-managed proxy. The credentials never reach the Actor. Apify already has webhooks, plus Slack and Zapier integrations at the platform level, and any of them can fire when a run finishes. But they fire after the run and pass the dataset as a reference, not as data. Everything I actually wanted to do has to happen where the records are, using the credential the user picked for that run: cap the digest at 25 records, diff this run against the previous one, and link each number back to its dataset. A webhook can announce that the run has finished. It cannot decide what is worth announcing. One clarification first, because the names collide. MCP connectors are not the Apify MCP server. The server exposes Actors to AI clients as tools. Connectors let an Actor call out to external tools. Same protocol, opposite direction. Prerequisites - An Apify account. Mine is on the free plan, and connectors work on it I checked before I built anything . - Node.js 20 or newer, and the Apify command-line interface CLI for apify push . - A published Actor you can safely add a version to. - The official MCP TypeScript software development kit SDK https://github.com/modelcontextprotocol/typescript-sdk , version 1.30.0 in my build. There is no Apify-specific MCP client; you use the standard one. The design I tried first, and threw away The connector documentation https://docs.apify.com/platform/integrations/mcp-connectors/use-in-actors includes an example called the output-writing Actor. You declare several rules, each naming tool patterns your Actor accepts: { "mcpServers": { "url": " ", "tools": { "required": "send " } }, { "url": " ", "tools": { "required": "post " } }, { "url": " ", "tools": { "required": "write " } } } That declaration does two jobs at once. It filters the connector picker in the input form to connectors that can actually receive data, and it limits what the Actor may call at runtime. Both are useful, so I wrote my version of it and started listing patterns. Then I went and read what real services actually name their tools. Slack's messaging tool is send message . Notion's page creator is notion-create-pages . A Google Sheets server appends with append rows . Linear files an issue with create issue . My sink server more on that shortly uses send message and append rows . Count the prefixes I would need. send , post , write , append , add , create , insert , upsert , plus every vendor that prefixes its own name onto the front, which kills prefix matching outright. A required list is an AND, not an OR: each pattern in one entry must match something on the connector, so every alternative needs its own rule object. That is a growing table of guesses about other people's naming conventions. And the failure mode is silent. The user authorizes Notion, opens my Actor, and the picker is empty with no explanation. So I threw the name list out and declared this instead: { "properties": { "deliveryConnector": { "title": "Delivery connector", "description": "An MCP connector from your account to deliver this run's results through.", "type": "string", "resourceType": "mcpConnector", "editor": "resourcePicker", "mcpServers": { "url": " ", "tools": { "readOnly": false } } } } } Any server, mutating tools only. readOnly: false is not a name guess; it keys off the tool's own MCP annotations https://modelcontextprotocol.io/docs/concepts/tools , so it works across services that agree on nothing else. It also gives users a guarantee worth stating plainly in the field description: this Actor cannot read anything through your connector. A tool the upstream server marks read-only is invisible to it. I will be honest about the cost of that trade, because it is real. readOnly: false admits any mutating tool, which on a broadly scoped connector could include a delete. Tightening it with destructive: false does not help: the MCP specification's default for an unannotated tool is destructive, so that rule would exclude every unannotated tool on every server, which is most of them. No declaration available today says "write-shaped tools only" without also saying "and only from services whose naming I guessed right." So I picked the failure that is visible the user sees the wrong tool in the log and sets an override over the one that is silent an empty picker . The mitigation I point users to is the per-connector tool allowlist in Apify Console. It is checkbox-level, and it applies to every Actor. Connecting from inside the run Every Actor run gets two environment variables https://docs.apify.com/platform/actors/development/programming-interface/environment-variables : ACTOR MCP CONNECTOR BASE URL and APIFY TOKEN . You append the connector ID to the base URL and authenticate with the run's token. That is the entire integration surface. js import { Actor, log } from 'apify'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; / Open an MCP session to one connector through the Apify MCP proxy. / async function connect connectorId { const baseUrl = process.env.ACTOR MCP CONNECTOR BASE URL; const token = process.env.APIFY TOKEN; if baseUrl { throw new Error 'ACTOR MCP CONNECTOR BASE URL is not set, so MCP connector delivery is unavailable. ' + 'This variable is injected by the Apify platform; delivery only works in a run on the platform, ' + 'not in a purely local run.', ; } if token { throw new Error 'APIFY TOKEN is not set - the Actor cannot authenticate to the Apify MCP proxy.' ; } const transport = new StreamableHTTPClientTransport new URL ${baseUrl.replace /\/+$/, '' }/${connectorId} , { requestInit: { headers: { Authorization: Bearer ${token} } } }, ; const client = new Client { name: 'florida-license-records-scraper', version: '0.2.0' } ; await client.connect transport ; return client; } The replace /\/+$/, '' is there because I concatenate a path onto a value I do not control, and a trailing slash would have produced a double slash. Small, but it is the kind of thing that fails in production and never locally. Note that the token belongs to the user who started the run, not to me. The Actor authenticates to the proxy as them, and the proxy injects their Slack or Notion credential upstream. I never hold anything. The part nobody warns you about: filling a stranger's arguments This is the part that took the longest, and it is embarrassingly simple to state. I do not know the tool I am calling. Slack wants { channel, text } . A Sheets server wants { spreadsheetId, values } . Notion wants a parent and a page body. I am one Actor that has to satisfy whichever of those the user's connector exposes, and I find out at runtime. The answer is to stop thinking in argument names and start thinking in roles. I build one payload in four shapes a title, a Markdown body, a row matrix, the raw records , then read the tool's own JSON schema and assign by role: / Property-name heuristics used to fill a tool's arguments from the payload. / const ARG ROLES = { role: 'target', type: 'string', test: /^ channel|channel ?id|conversation|room|recipient|to|chat|thread /i }, { role: 'target', type: 'string', test: / page|parent|database|spreadsheet|sheet|document|file|table|repo|project ?id ?$/i }, { role: 'title', type: 'string', test: /^ title|subject|name|heading|summary $/i }, { role: 'text', type: 'string', test: /^ text|message|content|body|markdown|description|comment|note $/i }, { role: 'rows', type: 'array', test: /^ rows|values|records|items|data|entries $/i }, { role: 'url', type: 'string', test: /^ url|link|href|source $/i }, ; export function buildToolArguments tool, payload, config { const schema = tool.inputSchema ?? {}; const properties = schema.properties ?? {}; const required = new Set schema.required ?? ; const args = {}; const matched = ; for const name, spec of Object.entries properties { const type = Array.isArray spec?.type ? spec.type 0 : spec?.type; const role = ARG ROLES.find r = r.test.test name && r.type === type || type === undefined ?.role; if role continue; let value; if role === 'target' value = config.target; else if role === 'title' value = payload.title; else if role === 'text' value = payload.text; else if role === 'rows' value = payload.rows; else if role === 'url' value = payload.url; if value === null || value === undefined continue; args name = value; matched.push ${name}<-${role} ; } // The user's own JSON always wins, so nothing is unreachable. Object.assign args, config.extraArguments ; const missing = ...required .filter name = args name === undefined ; if missing.length 0 { throw new Error Tool "${tool.name}" requires argument s this Actor could not fill: ${missing.join ', ' }. + Set them explicitly in "deliveryArguments", e.g. {"${missing 0 }": "..."}. + The tool accepts: ${Object.keys properties .join ', ' || ' no documented properties '}. , ; } return { args, matched }; } Yes, these are heuristics. I am not going to pretend otherwise. What makes it acceptable is the two escape hatches around it: deliveryTool forces the tool by name, and deliveryArguments is a JSON object merged in last that overrides anything I inferred. Every run logs the mapping it used channel<-target, text<-text , so a user who gets a surprise can read exactly what I did and correct one field. The same run also logs every tool the connector has exposed. That turned out to matter more than I expected, and I will come back to it. Testing this without giving a credential away I hit the wall every developer building a connector integration will hit. To test delivery you need an authorized connector. To authorize a connector you either complete an OAuth handshake or paste an API key. Neither belongs in a development loop, and I was not going to point a half-finished Actor at a real workspace to find out what my payload looks like. So I wrote the destination. It is 124 lines of Node.js with no dependencies. It speaks the Streamable HTTP transport, requires no authentication, and exposes tools shaped like the ones real services publish: js const TOOLS = { name: 'send message', description: 'Post a message to a channel.', inputSchema: { type: 'object', properties: { channel: { type: 'string', description: 'Channel name or ID, e.g. licenses.' }, text: { type: 'string', description: 'Message body Markdown .' }, }, required: 'channel', 'text' , }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, }, { name: 'list channels', description: 'List the channels available. Read-only; present so tool filtering can be observed.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, ; A third entry I trimmed from that snippet, append rows , covers the spreadsheet shape. Every call gets appended to a received.jsonl file, so I can read what arrived. list channels is a deliberate tripwire. It is annotated read-only, my Actor declares readOnly: false , and so if the platform's enforcement is real it should never reach my code. Apify's proxy runs server-side, so the sink needs a public address. A Cloudflare quick tunnel https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/ gives you one with no account: cloudflared tunnel --url http://127.0.0.1:8787 Then in Console, under Settings API & Integrations MCP connectors , I added a connector pointing at that hostname. Two things surprised me. The URL field offers a short list of known servers Sentry, Notion, and Linear but accepts any URL, and it validates it live. Console reached my tunnel and turned the field green before I saved. The API key field, meanwhile, is optional. With a name and a validated URL, Save lights up. A server that needs no authentication gets a connector that carries no credentials, and that is the entire reason I could build and test this today without touching a secret. Console then does its own handshake to discover the tools. My sink's log shows it: initialize , notifications/initialized , tools/list , at the moment I clicked Save. Those tools are frozen at authorization time add a tool to your server later and you have to re-authorize . What the platform actually did Six runs, all on the Florida Actor, all real. The full path is: run finishes, records are pushed and charged, then the delivery step opens an MCP session and calls one tool. | Run | Build | What I was testing | Result | Took | |---|---|---|---|---| | JOf1516fWh50aeK1J | 0.2.1 | First delivery through the proxy | 8 records extracted, 5 delivered via send message in 1,432 ms | 17s, $0.001 | | ErR2dnFGliK5tHAQz | 0.2.1 | A deliveryTool name that is filtered out | Delivery refused with the tool list; run still succeeded | 13s, $0.001 | | kqxAk4CjDSndyzpsP | 0.2.1 | Monitor mode change alerts delivery | Baseline of 36 licenses, 20 changes, 4 delivered | 46s, $0.003 | | z4BIfdoBTfAasY0L2 | 0.2.1 | Destination unreachable tunnel killed | Delivery failed, run succeeded, status message ruined | 18s, $0.001 | | Cgtg9neygILNmsjnE | 0.2.2 | Same, after the fix | Status message readable | 7s, $0.001 | | aZNkzKseabEJ33SOt | 0.2.2 | Regression on the happy path | 5 delivered via send message in 966 ms | 14s, $0.001 | Delivery costs about a second of run time. Do the arithmetic on the pay-per-result side: at $0.002 per record, a 500-record run bills $1.00 and the delivery adds roughly 1.4 seconds of platform time well under a tenth of a cent at 4 GB . It is free relative to the work the Actor already did. The line I care most about is this one, from JOf1516fWh50aeK1J : INFO MCP delivery: connector exposes 2 tool s to this Actor: send message, append rows The connector has three tools. Console shows three. My Actor saw two. list channels the read-only tripwire was filtered out of tools/list by the proxy, because my input schema said readOnly: false and the platform holds the Actor to what it declared. I did not have to write a line of filtering. That is the security model working, and it is the single most convincing thing I saw all day. What broke I ruined my own status message. When I killed the tunnel, the Apify proxy returned the upstream failure nested verbatim inside its own JSON remote procedure call JSON-RPC error, a Cloudflare 1033 page, escaped, inside a proxy error, inside a transport error. My code appended that to Actor.setStatusMessage . The result was several hundred characters of escaped JSON where a sentence belongs, on run z4BIfdoBTfAasY0L2 . The fix is boring and I should have written it first: export function deliveryStatusNote report { if report.status === 'delivered' return Delivered to your "${report.tool}" connector. ; if report.status === 'skipped' return ' MCP delivery skipped nothing to deliver .'; const first = report.error ?? 'unknown error' .split '\n' 0 ; const short = first.length 160 ? ${first.slice 0, 157 }... : first; return MCP delivery failed: ${short} full details in the log and in the DELIVERY REPORT key-value record . ; } The full error still goes to the log and to a DELIVERY REPORT record in the run's key-value store. The status message gets a sentence. Treat any error text that crossed a proxy as untrusted input to your own user interface, because it is. A connector's URL is immutable. My quick tunnel gets a new hostname every restart, and when I opened the connector to update it, the URL field was disabled. Name and tool allowlist are editable; the server address is not. I made a second connector. Fine for a test, worth knowing before you build a workflow around one. Delivery must never fail the run. This is the one I got right on the first pass, and only because I had been burned before. By the time delivery runs, the records are in the dataset and the user has been charged for them. Failing the run at that point bills somebody for a failure: the single worst outcome available on pay-per-result monetization https://docs.apify.com/platform/actors/publishing/monetize . So every delivery failure is caught, logged, written to DELIVERY REPORT , and summarized in the status message. All six runs above show Succeeded , including the two where delivery failed. That is deliberate. I also kept the whole thing off the live Actor while I worked. Version 0.2 is tagged beta . latest still points at 0.1.8, and every run above specified -b beta . Users on Apify Store never saw a byte of this until I was ready. The live delivery Everything above was tested against my sink server, and that was the right order: no real credential touches anything until the code has proven itself against a destination I control. But "I expect Notion to work" is a weaker sentence than "it did," so before publishing this I authorized a real connector Notion , through the OAuth flow in Console, granted access to a single page in my workspace, named "Apify deliveries." One page is all a delivery Actor needs, so one page is all it gets. It took three runs, and I am including all three because the two misses are the feature's own diagnostics doing their job: - LETB6mtr5TnaodNW6 : first contact. The proxy showed my Actor 14 Notion tools, and my name scorer picked notion-create-file-upload it saw "create" and "file" and liked them . That failed immediately, asking for a filename I had not filled. Wrong guess, loud failure. And the log printed all 14 tool names, which is exactly what the log-the-tool-list decision earlier in this article was for. - 6bOlNe9C6x8X0Rj6Y : I forced deliveryTool: "notion-create-pages" . Its one required argument, pages , is an array of structured page objects that no role heuristic of mine can honestly fill, and the error message named every argument the tool accepts. A dead end, reported as one. - QNvsLyZR9I4fGAeG2 : notion-create-comment , with the target page's ID in deliveryTarget . The mapper matched page id<-target and markdown<-text , and the run delivered 5 of its 8 extracted records in 1,654 ms. Status message: one sentence. And when I reloaded the Notion page, the comment was sitting on it: headline, the license table, the link to the full dataset. All of it written into my workspace by a paid Actor run through a credential the Actor never saw. Both misses ended in Succeeded runs, which is the never-fail-the-run rule from earlier holding up against a real service, not just a killed tunnel. One wrinkle worth reporting: Notion renders comment text as plain text, so the Markdown table arrives with its pipes showing. Every record is legible; it is just not pretty. The fix is a per-service formatting hint, and it is on the list. What I am still not claiming: Slack. The transport is identical and the argument shaping is driven by the tool's own schema, so I expect send message to take my payload the way the sink's did. But I have not run it, and this article only states what a run ID can back. What I would do differently Write the sink server first, not third. I built the Actor side, then the sink, then discovered that half my design questions what does the payload look like, which argument gets the table were only answerable by looking at what arrived. That was the wrong order. Log the tool list before you need it. Printing every tool the connector exposed started as a debugging line and became the feature's documentation. It is how a user learns what to put in deliveryTool , and it is how I discovered the proxy was filtering. Decide early that delivery is a digest, not an export. I default to 25 records in the message plus a link to the full dataset. A chat API will not take 500 rows and should not be asked to. The dataset is still the export; the connector is the notification. Assume every argument name will be wrong. The escape hatch deliveryArguments is not a fallback, it is the design. Any integration that has to satisfy tools it has never seen needs a way for the user to say "no, like this." Next steps The delivery code lives in src/delivery.js of the Florida license Actor https://apify.com/cblu/florida-license-records-scraper , currently on build 0.2.2 under the beta tag while I run it for a few weeks before promoting it. The pattern is not Florida-specific. The module takes records and a connector ID, and I am porting it to my Texas and California license Actors next. The use case I actually want is the scheduled one. My Actor's monitor mode reports only what changed since the last run: a license suspended, a renewal, one entering its expiration window. On a schedule, with a connector attached, that stops being a dataset nobody opens and becomes a message in the channel where the compliance team already works. Run kqxAk4CjDSndyzpsP is that path working end to end. If you publish an Actor, ask where its output is supposed to end up. If the honest answer involves a human copying a CSV file into something else, a connector removes that step, and the connector collection https://apify.com/store/collections/mcp-connectors in the store is a reasonable place to see who else has already done it.