I built a small agent that checks HubSpot for deals with no logged activity in 7+ days and posts a daily stalled-pipeline summary to Slack. The interesting part isn't the use case — it's the plumbing. Instead of hardcoding a HubSpot-fetch node and a Slack-post node in n8n, I exposed both as MCP (Model Context Protocol) tools and let an agent loop decide when to call them. For a single fixed workflow, n8n is faster to ship. For this one, I wanted the same tool-calling layer to be reusable by any agent I build later — a Slack bot, a CLI, a scheduled job — without rewriting the HubSpot and Slack glue every time.
- 1A HubSpot account with API access — specifically a private app token with deal-read scope
- 2A Slack workspace where you can create an app and generate a bot token with chat:write scope
- 3Python 3.10+ (or Node 18+ if you prefer TypeScript), and somewhere to run a cron-triggered process — a small VM, a container, or a managed scheduler
- 4An MCP SDK — check the current Anthropic MCP docs for the Python or TypeScript SDK, since package names and method signatures have shifted release to release
- 5An LLM API key with tool-calling support
- 6Basic familiarity with cron or a task scheduler
Why MCP instead of another n8n workflow
I already run n8n on a small GCP box for the podcast and outreach automations from an earlier post. The honest reason I didn't just bolt on another n8n workflow here: n8n workflows are one-off wiring. If the HubSpot connection or the Slack connection needs to be reused by a different agent later — say, one that also answers "what's the status of deal X" on demand — I'd be duplicating the same HTTP calls in a second workflow. MCP separates "how do I talk to HubSpot and Slack" from "what does the agent do with that." Same tool server, different agent logic, and I only maintain the integration code once.
Step 1 — Set up the MCP server exposing HubSpot and Slack tools
An MCP server exposes a set of tools an LLM can call, each with a name, a description, and a JSON schema for arguments. I ran one process exposing two tools: get_stalled_deals and post_slack_summary. Keep the tool surface as small as you can get away with — every tool you expose is something the agent might call in the wrong order or with the wrong arguments, and fewer tools is easier to reason about while you're still verifying behavior.
# server.py — illustrative. Check the current MCP Python SDK docs
# for the exact decorator/registration names — this shifted between
# early MCP releases and I'm not claiming this is byte-for-byte current.
from mcp.server import Server # package/module name may differ
from mcp.server.stdio import stdio_server
import httpx, os
app = Server("revops-tools")
HUBSPOT_TOKEN = os.environ["HUBSPOT_PRIVATE_APP_TOKEN"]
SLACK_TOKEN = os.environ["SLACK_BOT_TOKEN"]
@app.tool()
async def get_stalled_deals(days_inactive: int = 7) -> list[dict]:
"""Return open HubSpot deals with no logged activity in N days."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.hubapi.com/crm/v3/objects/deals",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
params={"properties": "dealname,amount,dealstage,notes_last_updated"},
)
deals = resp.json().get("results", [])
# filter in Python — HubSpot's search API can do this server-side too,
# but I wanted the filter logic visible and testable on my side
stalled = [d for d in deals if _days_since_activity(d) >= days_inactive]
return stalled
@app.tool()
async def post_slack_summary(text: str, channel: str = "#revops-alerts") -> dict:
"""Post a message to Slack. This agent should only call this to
summarize — never to take action on a deal."""
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {SLACK_TOKEN}"},
json={"channel": channel, "text": text},
)
return resp.json()
if __name__ == "__main__":
stdio_server(app) # or the HTTP/SSE transport, depending on how
# your agent host connects — see MCP transport docsWhy one server, not two
I could have run a separate MCP server per integration — one for HubSpot, one for Slack — which is arguably cleaner once you're maintaining several agents. I ran both tools off a single process because this is a single-purpose agent with one job: summarize stalled deals. One process is one thing to keep alive, one log file to tail, one place to rotate credentials. If I add a third data source later (Google Sheets for quota tracking is the likely next one), I'll probably split it out — but I wouldn't reach for a microservice-per-tool architecture on day one.
Step 2 — Wire the agent loop
The agent loop is the part MCP actually standardizes. Your LLM client discovers the tools a server exposes through a list-tools call, and the model decides when to invoke them based on the conversation — not based on a hardcoded sequence you wrote. You're not writing "if deal is stale, call Slack"; you're writing a system prompt describing the job and letting the model chain the two tool calls itself.
# agent.py — illustrative agent loop. The exact MCP client class names
# and the tool-calling loop helpers have changed across MCP SDK versions,
# so treat method names here as approximate — verify against whatever
# SDK version you actually pin.
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
SYSTEM_PROMPT = """You are a RevOps assistant. Once a day, call
get_stalled_deals to find deals with no activity in 7+ days, then
call post_slack_summary to post a short, scannable summary to
#revops-alerts. Group by deal stage. Never modify or close a deal —
you only have read access to HubSpot and write access to Slack."""
async def run_daily_check():
server_params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools() # get_stalled_deals, post_slack_summary
client = anthropic.Anthropic()
messages = [{"role": "user", "content": "Run the daily stalled-deal check."}]
response = client.messages.create(
model="claude-sonnet-4-5",
system=SYSTEM_PROMPT,
max_tokens=1024,
tools=tools, # convert MCP tool schema to the Anthropic
# tool-use format here
messages=messages,
)
# Loop: while response contains tool_use blocks, execute each
# one via session.call_tool(name, args), append the results as
# tool_result messages, and call messages.create again until
# the model stops requesting tools. Check the current MCP +
# Claude tool-use docs for the recommended loop shape — this
# detail changes across SDK versions.Step 3 — Schedule it
MCP doesn't do scheduling — that part is still on you. I run the agent script as a cron job on the same small Linux box that already hosts the n8n setup from the earlier post, at 8:30am IST, before the RevOps standup. A systemd timer and a plain cron entry both work fine here since this process runs, does its one job, and exits — there's no need for a long-running server for this particular agent. If you don't want to babysit a VM, a scheduled GitHub Action or a Cloud Scheduler + Cloud Run job does the same thing without you managing uptime yourself.
MCP security is still maturing
Auth and access-control patterns around MCP are still maturing in 2026 — treat every MCP server you stand up as a trust boundary, not a convenience layer. Don't expose a tool that closes deals, deletes records, or sends anything outside of a Slack summary without a human-approval step in between. This agent gets read-only HubSpot access and Slack-post-only access on purpose — there's no update_deal or delete_deal tool on the server at all, so there's nothing destructive for a misfired tool call to reach even if the model gets confused.
Step 4 — Mistakes I made
- 1The first version gave the agent a generic hubspot_request tool that took an arbitrary endpoint and method as arguments. That's basically a raw HTTP proxy with extra steps — the model could technically call any HubSpot endpoint, including write ones. I replaced it with named, narrow tools (get_stalled_deals only), so the blast radius of a bad tool call is obvious from the tool name alone.
- 2I let the model pick days_inactive per run at first, and it occasionally used 3 days instead of 7, which flooded the channel with deals that were barely a weekend old. Fixed it by making the threshold a fixed default in the tool signature itself, and only mentioning the policy in the system prompt — not something the model free-forms on every call.
- 3I didn't rate-limit the Slack tool, so a retry-happy agent loop (my bug, not the model's) posted the same summary three times one morning. Added a simple idempotency check — hash the message content plus the date, skip if already posted today — before letting anything actually hit the Slack post endpoint.
Building something similar
If you run a RevOps or GTM team and want this kind of daily pipeline-hygiene agent without building the MCP plumbing yourself, this is the kind of automation I build for a living — reach out if you want a version wired to your own CRM and Slack workspace.
Cost and time breakdown
Total time was about 6 hours spread over two evenings — most of it spent re-reading MCP SDK docs since method names shift between versions, not writing the actual logic. Hosting cost is effectively zero since this runs as a cron job on a box that was already paying for itself. The only recurring cost is the LLM call, and one short exchange a day is a rounding error, not a line item worth tracking closely. MCP didn't save time over a single n8n workflow for this specific use case, and I wouldn't pretend otherwise — what it bought me was a HubSpot tool and a Slack tool that any future agent I build can reuse without touching the integration code again.
