Skip to content

tutorial

Chapter 2 of 5

Chapter 2 — Swapping the transport

by Rod Rivera Published

Declare an MCP server, point three import lines at it, and see that nothing else in the project has to move.

Two files change. That is the whole swap.

1. Declare the server

integrations.yml gains an mcp_servers: block. Everything above it — the model group, the channels — stays exactly as it was:

mcp_servers:
  - name: hubspot_crm
    url: http://127.0.0.1:8931/mcp
    tool_timeout: 15

name is the id the skills will reference. It must match byte for byte; a typo here is caught at model load rather than at train time, for reasons chapter 3 gets into.

url has to be http or https. That is not a style preference — MCPServerSpec rejects any other scheme outright, which rules out stdio servers entirely. Chapter 3 covers what that means in practice.

Pointing at HubSpot’s own MCP server later is the same two lines:

mcp_servers:
  - name: hubspot_crm
    url: https://mcp.hubspot.com/anthropic
    token: ${HUBSPOT_ACCESS_TOKEN}

2. Point the imports at it

Each skill names the tools it wants with mcp/<server>:<tool>. Here is the complete diff for all three skills:

--- skills/identify_customer/skill.md
+++ mcp_variant/skills/identify_customer/skill.md
 import_tools:
-  - find_contact_by_email
+  - mcp/hubspot_crm:find_contact_by_email

--- skills/check_tickets/skill.md
+++ mcp_variant/skills/check_tickets/skill.md
+import_tools:
+  - mcp/hubspot_crm:list_open_tickets

--- skills/log_interaction/skill.md
+++ mcp_variant/skills/log_interaction/skill.md
+import_tools:
+  - mcp/hubspot_crm:add_timeline_note

That is every changed line in every skill. Six lines, all of them inside YAML frontmatter.

Note the asymmetry. identify_customer already had an import_tools: block, because find_contact_by_email was a global tool it imported by name — so its change is a substitution. The other two called local tools defined in their own tools.py, which needed no import at all, so their change is an addition. Different starting points, same destination.

What the model sees

get_bare_tool_name strips the prefix, so the model is offered find_contact_by_email, not mcp/hubspot_crm:find_contact_by_email:

  ✓ identify_customer imports find_contact_by_email  server=hubspot_crm llm sees
    'find_contact_by_email'

This is the mechanical reason the instructions do not have to change. The skill’s prose says “call find_contact_by_email”, and after the swap there is still a tool called find_contact_by_email in scope, with the same arguments and the same result keys. The routing changed underneath; the name did not.

What did not change

Worth listing, because the list is longer than the diff:

  • Every line of instruction prose, in all three skills.
  • The ordered block in log_interactiondraft_summary then write_note.
  • The requires_confirmation constraint, and both its utterances.
  • memory.yml, at project level and in every skill.
  • lib/hubspot.py, the CRM client. The MCP server calls straight into it.
  • agent.yml, the persona, the rules.

lib/hubspot.py is the quiet one. The MCP server imports the same client the REST tools used and calls the same three functions. The error taxonomy — the difference between contact_not_found and crm_unreachable — is preserved because it was never in the transport layer to begin with. It was in the client, and the client did not move.

Run it

make mock          # terminal 1: the mock CRM
make mcp-server    # terminal 2: the MCP server in front of it
make mcp-swap      # move the project onto MCP
make train && make chat

make mcp-status says which transport is live:

MCP  — integrations.yml declares mcp_servers:
    - mcp/hubspot_crm:list_open_tickets
    - mcp/hubspot_crm:find_contact_by_email
    - mcp/hubspot_crm:add_timeline_note

And make mcp-restore puts REST back. The swap is deliberately reversible: the REST project is the baseline the swap is measured against, so it stays on disk rather than being overwritten. Delete it and the lesson goes with it.

Verify with the engine, not just the eye

Before training, ask the engine whether it accepts the project:

uv run python -c "from pathlib import Path; \
    from rasa.mantle.validation import validate_project; validate_project(Path('.'))"

It is free, offline, and needs no credentials. On the swapped project it passes, which means the imports parsed, the server block validated, and no skill is referencing a tool that no longer exists locally.