September 16: One Workspace for Every Session with Plotly Studio Projects ✨ Register now

Matt Brown

Matt Brown

August 25, 2026

How we built an AI-powered messaging framework app using Dash MCP

A few weeks back, we talked about why we turned our product marketing brain into an MCP server: so anyone at the company could ask Claude a tricky positioning question and get back the same answer our PMM team would give. In this post, we’ll cover the "how." We'll walk through the shape of the application so you can adapt the pattern to whatever institutional knowledge you're sitting on, whether that's a messaging framework, a sales playbook, or a support knowledge base.

Everything below is built on Dash's native MCP server support (in Dash 4.3), deployed to Plotly Cloud.

The two ways to expose something as a tool

The first decision in building an MCP server is deciding what an "MCP tool" actually is in your app. Dash gives you two paths, and we ended up using both.

Callbacks, automatically. If you pass enable_mcp=True to your Dash constructor, the _mcp endpoint is enabled (required for access), and then every callback in your app is exposed as a tool by default, no extra code required.

from dash import Dash
app = Dash(__name__, enable_mcp=True)

An agent connecting to this app can see your layout, your callbacks, and how they're wired together, and it can call them the same way a user's browser would. That's a reasonable starting point, but Dash’s callbacks were built to drive dropdowns and update a webpage, not to answer "how do we stack up against Competitor X." We needed tools that were shaped around questions, not UI state.

Plain functions. We used the mcp_enabled decorator for this. It lets you expose any Python function as a tool, independent of your app's layout:

from dash.mcp import mcp_enabled
@mcp_enabled(name="get_competitive_positioning", expose_docstring=True)
def get_competitive_positioning(product: str = "plotly-studio") -> dict:
    """Return the competitive objection-handling playbook for a Plotly
    product line: responses to competitor claims, cost concerns, security,
    and enterprise questions."""
    return _load_competitive_playbook(product)

This is the shape almost all of our tools ended up taking: a handful of parameters an agent can infer from conversation, and a docstring written less like internal documentation and more like an instruction to whoever's calling it. We treated expose_docstring=True basically as mandatory. Without it, Dash falls back to a bare "MCP tool" description, which tells the model nothing. Claude reads whatever description it gets every time it's deciding whether and how to call the tool, so a missing or vague docstring gets you vague tool usage.


You can operate on either an opt-in basis for callbacks, or opt-out. We chose opt-in for this case so we could control the exact callbacks we wanted to expose. See our documentation to learn how each works and to decide which works best for your situation.

Shaping the messaging framework as tools, not just content

The tempting shortcut here is to expose one big tool like get_full_context() that dumps the whole messaging document and lets the model sort it out. We actually kept a tool like that, since it's useful when an agent wants a full briefing before a big writing task, but leaning on it as the only tool worked poorly early on: bloated responses, slower answers, and Claude occasionally reaching for the wrong section entirely.

What works better is decomposing the framework along the same lines our PMM team already thinks in, one tool per category/product line:

from dash.mcp import mcp_enabled
@mcp_enabled(expose_docstring=True)
def get_personas(product: str = "plotly-studio") -> dict:
    """Return the ICP persona profiles for a Plotly product line: each
    audience's role, pain points, motivations, and decision drivers."""
    ...
@mcp_enabled(expose_docstring=True)
def get_competitor_profile(category: str, product: str = "plotly-studio") -> dict:
    """Return a detailed competitor profile by category for a Plotly
    product line. Valid categories vary by product — call list_products()
    to see what each product line has broken out."""
    ...
@mcp_enabled(expose_docstring=True)
def get_messaging_matrix(product: str = "plotly-studio") -> dict:
    """Return the pain-to-solution messaging matrix for a product line:
    each customer pain point mapped to our positioning and proof points."""
    ...

We also broke out a list_products() tool with no arguments, so that when someone asks about a product line the assistant hasn't seen mentioned yet, it can check what's actually covered before guessing at a key. A small and simple tweak that eliminated an entire category of "I don't have information on that" wrong turns.

One thing we deliberately didn't do: constrain product with a Literal["plotly-studio", "dash-enterprise", ...] type hint, even though Dash's MCP layer will happily turn a Literal into a JSON Schema enum that the model can be forced to comply with. Our product line list is going to grow (new product lines get a messaging framework written for them every quarter or so), and a Literal would mean a code change and a redeploy every time that happens. We'd rather take the small risk of Claude passing a slightly wrong string and getting a clear "unknown product" response back than suffer the redeploy cadence. list_products() should cover most of the discovery problem, as an agent that isn't sure what's valid can just ask.

Your use case might benefit from using a Literal, so if your valid-value set is small and fixed, a Literal is worth it and will cut down on retries. Just be aware of the pros and cons.

Structuring the content so both Claude and humans can navigate it

None of this works if the underlying content is a wall of text. Before anything becomes a tool, it has to be organized as content, and we built that structure for two readers at once: a Product Marketing Manager (PMM) editing content in a pull request, and Claude deciding what to load mid-conversation.

The pattern is one directory per topic area, one file per category, and an index.md at the top of each directory that does nothing but route:

/{product_line}/context/messaging-framework/
  product-foundations.md
  personas.md
  messaging-matrix.md
  competitive-positioning.md
  cta-library.md

Next to the messaging framework, we keep competitive analysis, like this:

/{product_line}/context/competitive-landscape/
  challengers.md
  incumbents.md
  strategic-gaps.md

The payoff of building it this way is that there's no separate copy of the content anywhere. get_personas() reads personas.md directly. There's no CMS, embeddings index, or sync job translating the editable version into the servable version. The file that the PMM changes in a PR is, on its very next call, the exact file Claude reads. With this structure we get: version history, PR review, and diffs, on the same source of truth the tools are built on, not a second one someone has to remember to update.

We also include other foundational content, like sales enablement, blog posts, video scripts, etc. in the same directory.

/{product_line}/sales-enablement/{some_article}.md

Basically anything that describes the product in detail, it’s nuances, and uses the approved Plotly tone of voice, to help Claude write more accurate and natural sounding copy.

Deploying and authenticating

Once the app was deployed to Plotly Cloud, connecting to it is easy: Plotly Cloud's own sign-in gates the MCP endpoint the same way it gates the app itself, and a client like Claude Desktop or Claude.ai that supports remote MCP servers handles the rest of the OAuth handshake without any custom client registration or Proof Key for Code Exchange (PKCE) implementation on our end. Someone adds our Plotly Cloud MCP URL as a custom connector, signs in with their Plotly Cloud account once, and from then on their own client holds and refreshes their own token. Plotly Cloud and the connecting client's own MCP support did it for us; there’s nothing custom we need to do on this front which is a huge time saver.

One last note about the app hosting: make sure you flip your app to "Always on." That ensures the MCP server is reachable by anyone making a request without waiting for the app to wake up, which can take a minute or two, and the pricing is very reasonable for the starter tier app size.

What we didn't expose

Dash's MCP layer only surfaces your layout and callbacks in the same shape a browser already sees; it doesn't hand an agent your source code or internal variables, so all we had to do was mark the appropriate functions with mcp_enabled=True. Beyond that, these are a few habits we settled on:

  • Every tool function's docstring is written assuming an external reader; nothing internal-only goes in there, since it's sent straight to the model as context whenever expose_docstring=True.
  • Tools return the content our PMM team wants surfaced, not the underlying data structures we use to store it. A competitor profile might be a nested internal object with editorial notes and version history, but the tool returns a clean payload built for the specific question.
  • Our tool responses carry a status flag for content that's still in draft (product lines whose messaging hasn't been validated against real customer data yet), so an agent, and the person reading its answer, knows to treat that content as directional, not final.

Wrapping up

This project turned out to be a much easier lift than we originally thought by virtue of using Plotly’s new built-in MCP servers. Claude Code was able to assist for the majority of the build, and we had it up and running in an afternoon, and rolled out company wide in just a couple of days. That’s an awesome payoff for a Friday afternoon side-of-the-desk project like this.

So, if you're sitting on a body of institutional knowledge that only a few people know how to use well, the technical lift to get it into their hands is really small. Break down your information retrieval into categories and write a function for each one with a good docstring, and you're ready to go. Claude can help you get there quickly, especially if you're using the new Dash Docs MCP, and you'll be up and running in no time.

bluesky logo
x logo
instagram logo
youtube logo
medium logo
facebook logo

Product

© 2026
Plotly. All rights reserved.
Cookie Preferences
AICPA Icon
ISO 27001
ISO 27701
ISO 42001