ChartKitMCP docs

Python and Google Slides

Create a chart from Python and place it on a slide with a Google service account, editable in ChartKit.

This guide builds a chart from Python with a team API key and puts it on a Google Slides slide with a service account. The image on the slide carries an alt text that points to the ChartKit chart, so analysts who open the deck with the ChartKit extension can edit the chart there. The full script at the end runs as is.

Before you start

  • A team API key in the CHARTKIT_API_KEY environment variable. See create a key.
  • A Google Cloud project with the Google Slides API enabled, and a service account. Locally, point GOOGLE_APPLICATION_CREDENTIALS at its JSON key file. On Cloud Run, GKE or a VM, the attached service account works without a key file.
  • The presentation shared with the service account's email address as an editor. On a shared drive, add the service account to the drive instead.
  • Python 3.9 or later.
Terminalbash
pip install requests google-api-python-client google-auth

Call the server

Each request is an HTTP POST with one JSON-RPC 2.0 message in the body, and the reply is plain JSON. The server keeps no session, so you can send tools/call directly without initialize first.

create_chart.pypython
import os
import uuid

import requests

MCP_URL = "https://www.getchartkit.app/mcp"
HEADERS = {
    "Authorization": f"Bearer {os.environ['CHARTKIT_API_KEY']}",
    "Accept": "application/json, text/event-stream",
}


def call_tool(name, arguments):
    response = requests.post(
        MCP_URL,
        headers=HEADERS,
        json={
            "jsonrpc": "2.0",
            "id": str(uuid.uuid4()),
            "method": "tools/call",
            "params": {"name": name, "arguments": arguments},
        },
        timeout=60,
    )
    response.raise_for_status()  # 401: the key is missing, unknown or revoked
    message = response.json()
    if "error" in message:  # invalid input, unknown tool or chart id
        raise RuntimeError(f"{message['error']['code']}: {message['error']['message']}")
    result = message["result"]
    if result.get("isError"):  # e.g. the team licence is not active
        raise RuntimeError(result["content"][0]["text"])
    return result["structuredContent"]


chart = call_tool("create_chart", {
    "chart_type": "clusteredBar",
    "table": {
        "categories": ["2021", "2022", "2023", "2024", "2025"],
        "series": [{"name": "Online sales"}],
        "values": [[41.2, 48.9, 57.3, 66.0, 78.4]],
    },
    "unit": "EUR m",
    "annotations": [
        {"kind": "cagr", "from": {"category_index": 0}, "to": {"category_index": 4}},
    ],
    "size": {"width_pt": 648, "height_pt": 259},  # the box on the slide
})
print(chart["chartId"], chart["imageUrl"], chart["slidesAltText"], chart["sizePt"])

A tool call fails in one of three places, and the function above checks each one.

WhereWhat it means
HTTP 401The key is missing, unknown or revoked.
error in the JSON-RPC replyThe input did not validate, or the tool or chart id does not exist. The message says which field is wrong.
result.isError is trueThe tool ran but could not finish, for example because the team licence is not active.

The chart input reference lists every field create_chart accepts. To check the connection or read the tool schemas, send initialize and tools/list the same way.

handshake.pypython
response = requests.post(MCP_URL, headers=HEADERS, timeout=60, json={
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "protocolVersion": "2025-06-18",
        "capabilities": {},
        "clientInfo": {"name": "reporting-pipeline", "version": "1.0"},
    },
})
print(response.json()["result"]["serverInfo"])  # {'name': 'chartkit-mcp', 'version': '1.0.0'}

tools = requests.post(MCP_URL, headers=HEADERS, timeout=60, json={
    "jsonrpc": "2.0", "id": 2, "method": "tools/list",
}).json()["result"]["tools"]
print([tool["name"] for tool in tools])

The response

Read result.structuredContent. The text block in result.content is written for an AI assistant and its wording can change.

Responsejson
{
  "jsonrpc": "2.0",
  "id": "5b0e…",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "clusteredBar chart. Chart id q3Vt8wXc_LmZ0aBf, editable in ChartKit. Hosted SVG …"
      }
    ],
    "structuredContent": {
      "chartId": "q3Vt8wXc_LmZ0aBf",
      "imageUrl": "https://www.getchartkit.app/api/charts/q3Vt8wXc_LmZ0aBf.png?v=2026-11-13T09%3A41%3A07.512Z",
      "svgUrl": "https://www.getchartkit.app/api/charts/q3Vt8wXc_LmZ0aBf.svg?v=2026-11-13T09%3A41%3A07.512Z",
      "slidesAltText": "ChartKit:q3Vt8wXc_LmZ0aBf",
      "sizePt": { "width": 648, "height": 258.75 },
      "model": { "table": { … }, "spec": { … }, "palette": [ … ] }
    }
  }
}
FieldUse
chartIdThe chart's id. Pass it to update_chart or get_chart_svg.
imageUrlPNG on a white background, rendered at twice the chart's size: sizePt × 8/3 pixels. Insert this into Slides.
svgUrlThe same chart as SVG, for web pages and documents.
slidesAltTextThe alt text description to set on the image, ChartKit: followed by the chart id.
sizePtThe size in points to insert the image at. It fits inside the size you sent.
modelThe stored ChartKit chart model and the palette it was drawn in.

With the MCP Python SDK

The official mcp package works too. Version 2 takes headers through an httpx2 client. JSON-RPC errors raise MCPError, and a tool error result comes back with is_error set.

sdk_client.pypython
import asyncio
import os

import httpx2
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client

MCP_URL = "https://www.getchartkit.app/mcp"


async def main():
    headers = {"Authorization": f"Bearer {os.environ['CHARTKIT_API_KEY']}"}
    # Keep the timeout: without it httpx2 waits 5 seconds at most.
    timeout = httpx2.Timeout(30.0, read=300.0)
    async with httpx2.AsyncClient(headers=headers, timeout=timeout) as http:
        async with Client(streamable_http_client(MCP_URL, http_client=http)) as client:
            try:
                result = await client.call_tool("create_chart", {
                    "chart_type": "clusteredBar",
                    "table": {
                        "categories": ["2021", "2022", "2023", "2024", "2025"],
                        "series": [{"name": "Online sales"}],
                        "values": [[41.2, 48.9, 57.3, 66.0, 78.4]],
                    },
                    "unit": "EUR m",
                })
            except MCPError as error:  # JSON-RPC error: bad input, unknown tool
                raise SystemExit(f"ChartKit error {error.code}: {error.message}")
            if result.is_error:  # tool error result
                raise SystemExit(result.content[0].text)
            chart = result.structured_content
            print(chart["chartId"], chart["imageUrl"], chart["slidesAltText"])


asyncio.run(main())
A bad key fails on connect

With the SDK, a 401 surfaces when Client opens the connection, as an ExceptionGroup that wraps an MCPError, not as an HTTP status. If the connection fails before any tool call, check the key first.

Fetch the image once

The PNG is rendered the first time someone requests imageUrl. Request it yourself before you call the Slides API, so Google's fetch during createImage gets a finished image.

fetch_png.pypython
requests.get(chart["imageUrl"], timeout=60).raise_for_status()

The URL belongs to one version of the chart. After update_chart, use the new imageUrl it returns; the old one returns 404.

Put the chart on a slide

Send the box the chart will fill as size when you create it, and insert the image at the sizePt the result returns. ChartKit lays the chart out for that box, and the extension reopens a chart at exactly the size it was drawn for. An image inserted at sizePt opens in ChartKit with the same layout and text size it has on the slide; a chart drawn for another size and scaled into the box would change when an analyst opens it.

One batchUpdate creates the image from imageUrl and sets its alt text. Slides applies the requests in order, so the second request can refer to the image the first one creates.

insert_chart.pypython
import uuid

import google.auth
from googleapiclient.discovery import build

credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/presentations"])
slides = build("slides", "v1", credentials=credentials, cache_discovery=False)

image_id = f"chartkit_{uuid.uuid4().hex[:16]}"
box_left, box_top, box_width, box_height = 36, 110, 648, 259  # the box sent as size
width_pt, height_pt = chart["sizePt"]["width"], chart["sizePt"]["height"]
left = box_left + (box_width - width_pt) / 2
top = box_top + (box_height - height_pt) / 2

slides.presentations().batchUpdate(
    presentationId=PRESENTATION_ID,
    body={
        "requests": [
            {
                "createImage": {
                    "objectId": image_id,
                    "url": chart["imageUrl"],
                    "elementProperties": {
                        "pageObjectId": SLIDE_ID,
                        "size": {
                            "width": {"magnitude": width_pt, "unit": "PT"},
                            "height": {"magnitude": height_pt, "unit": "PT"},
                        },
                        "transform": {
                            "scaleX": 1, "scaleY": 1,
                            "translateX": left, "translateY": top,
                            "unit": "PT",
                        },
                    },
                }
            },
            {
                "updatePageElementAltText": {
                    "objectId": image_id,
                    "title": "ChartKit",
                    "description": chart["slidesAltText"],
                }
            },
        ]
    },
).execute()
  • Sizes and positions are in points (PT) or EMU, with 12,700 EMU to a point. A 16:9 slide is 720 × 405 points. sizePt can be smaller than the box, for example a donut in a wide box, so centre the image in it.
  • objectId is optional. If you set one, it must be 5 to 50 characters of letters, digits, _, - or :, and unique in the presentation.
  • Google copies the image into the presentation at insert time. Later changes to the hosted PNG do not change the slide.

The alt text

The ChartKit extension finds its charts by the image's alt text description. Set description to slidesAltText exactly as returned, and title to ChartKit, which is what the extension writes for its own charts. Do not add other text to the description.

Analysts can edit the chart

When someone opens the deck in Chrome with the ChartKit extension, they can open the image in ChartKit and change its data, type or styling. ChartKit reads the full chart from the id in the alt text.

Update a chart from code

Call update_chart with the chart_id and only the fields that change, for example a new table next month. It returns a new imageUrl and keeps the chart id, so the alt text stays the same. To swap the picture on the slide, send a replaceImage request for the existing image with the new URL. With an API key, your code replaces the picture. The server only refreshes charts in decks that a signed-in person has open.

The chart keeps the box it was created for, so sizePt usually stays the same. If the new sizePt differs from the image on the slide, delete the image and insert the new one at sizePt: replaceImage keeps the old size.

Full script

The script draws the chart for the box under the slide title, adds a slide with that title, and centres the chart in the box. Pass an existing slide's object id as the second argument to use that slide instead. Set CHARTKIT_MCP_URL to call another ChartKit server, such as a preview deployment.

Download chartkit_slides.py

chartkit_slides.pypython
"""Draw a chart with the ChartKit MCP server and put it on a Google Slides slide.

The chart is drawn for the box under the slide title and lands as an image
whose alt text points back to the ChartKit chart, so anyone with the ChartKit
extension can open it in the deck and edit it.

Setup:
    pip install requests google-api-python-client google-auth
    export CHARTKIT_API_KEY=ck_team_...
    export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
    export CHARTKIT_MCP_URL=https://...   # optional, defaults to the production server

Share the presentation with the service account's email address as an editor.

Usage:
    python chartkit_slides.py PRESENTATION_ID            # adds a new slide
    python chartkit_slides.py PRESENTATION_ID SLIDE_ID   # uses an existing slide
"""

import os
import sys
import uuid

import google.auth
import requests
from googleapiclient.discovery import build

MCP_URL = os.environ.get("CHARTKIT_MCP_URL", "https://www.getchartkit.app/mcp")
SLIDES_SCOPES = ["https://www.googleapis.com/auth/presentations"]
EMU_PER_PT = 12700

SLIDE_TITLE = "Online sales grew 17% a year since 2021"
CHART = {
    "chart_type": "clusteredBar",
    "table": {
        "categories": ["2021", "2022", "2023", "2024", "2025"],
        "series": [{"name": "Online sales"}],
        "values": [[41.2, 48.9, 57.3, 66.0, 78.4]],
    },
    "unit": "EUR m",
    "annotations": [
        {"kind": "cagr", "from": {"category_index": 0}, "to": {"category_index": 4}},
    ],
}


class ChartKitError(RuntimeError):
    pass


def call_tool(name, arguments, api_key, timeout=60):
    """Calls one ChartKit MCP tool and returns the tool result."""
    response = requests.post(
        MCP_URL,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Accept": "application/json, text/event-stream",
        },
        json={
            "jsonrpc": "2.0",
            "id": str(uuid.uuid4()),
            "method": "tools/call",
            "params": {"name": name, "arguments": arguments},
        },
        timeout=timeout,
    )
    if response.status_code == 401:
        raise ChartKitError("ChartKit rejected the API key (HTTP 401). Check CHARTKIT_API_KEY.")
    response.raise_for_status()

    message = response.json()
    if "error" in message:
        error = message["error"]
        raise ChartKitError(f"{name} failed with JSON-RPC error {error['code']}: {error['message']}")

    result = message["result"]
    if result.get("isError"):
        text = " ".join(block.get("text", "") for block in result.get("content", []))
        raise ChartKitError(f"{name} returned an error: {text}")
    return result


def render_png(image_url):
    # The first request renders the PNG, so Google's fetch during createImage gets a finished image.
    response = requests.get(image_url, timeout=60)
    response.raise_for_status()


def page_size_pt(slides, presentation_id):
    page_size = slides.presentations().get(presentationId=presentation_id, fields="pageSize").execute()["pageSize"]

    def to_pt(dimension):
        return dimension["magnitude"] / EMU_PER_PT if dimension.get("unit") == "EMU" else dimension["magnitude"]

    return to_pt(page_size["width"]), to_pt(page_size["height"])


def add_title_slide(slides, presentation_id, title):
    """Appends a Title only slide with the given title and returns its object id."""
    slide_id = f"slide_{uuid.uuid4().hex[:12]}"
    title_id = f"title_{uuid.uuid4().hex[:12]}"
    slides.presentations().batchUpdate(
        presentationId=presentation_id,
        body={
            "requests": [
                {
                    "createSlide": {
                        "objectId": slide_id,
                        "slideLayoutReference": {"predefinedLayout": "TITLE_ONLY"},
                        "placeholderIdMappings": [
                            {"layoutPlaceholder": {"type": "TITLE", "index": 0}, "objectId": title_id},
                        ],
                    }
                },
                {"insertText": {"objectId": title_id, "text": title}},
            ]
        },
    ).execute()
    return slide_id


def insert_chart(slides, presentation_id, slide_id, chart, left, top, width, height):
    """Places the chart PNG in the given box (points) and sets the alt text ChartKit reads."""
    image_id = f"chartkit_{uuid.uuid4().hex[:16]}"
    slides.presentations().batchUpdate(
        presentationId=presentation_id,
        body={
            "requests": [
                {
                    "createImage": {
                        "objectId": image_id,
                        "url": chart["imageUrl"],
                        "elementProperties": {
                            "pageObjectId": slide_id,
                            "size": {
                                "width": {"magnitude": width, "unit": "PT"},
                                "height": {"magnitude": height, "unit": "PT"},
                            },
                            "transform": {
                                "scaleX": 1,
                                "scaleY": 1,
                                "translateX": left,
                                "translateY": top,
                                "unit": "PT",
                            },
                        },
                    }
                },
                {
                    "updatePageElementAltText": {
                        "objectId": image_id,
                        "title": "ChartKit",
                        "description": chart["slidesAltText"],
                    }
                },
            ]
        },
    ).execute()
    return image_id


def chart_box(page_width, page_height, top=110, margin=36):
    """The box under the title, in points."""
    return margin, top, page_width - 2 * margin, page_height - top - margin


def centred(box, size):
    """The image's position and size: the chart's size, centred in the box it was drawn for."""
    left, top, width, height = box
    return left + (width - size["width"]) / 2, top + (height - size["height"]) / 2, size["width"], size["height"]


def main():
    if len(sys.argv) not in (2, 3):
        print(__doc__)
        sys.exit(2)
    presentation_id = sys.argv[1]
    slide_id = sys.argv[2] if len(sys.argv) == 3 else None

    # Reads GOOGLE_APPLICATION_CREDENTIALS, or the service account attached to the
    # Cloud Run service, GKE pod or VM this runs on.
    credentials, _ = google.auth.default(scopes=SLIDES_SCOPES)
    slides = build("slides", "v1", credentials=credentials, cache_discovery=False)
    box = chart_box(*page_size_pt(slides, presentation_id))

    # ChartKit lays the chart out for the box and returns sizePt, the size to insert it at. The
    # extension reopens the chart at the size it was drawn for, so the edit view matches the slide.
    arguments = {**CHART, "size": {"width_pt": box[2], "height_pt": box[3]}}
    chart = call_tool("create_chart", arguments, api_key=os.environ["CHARTKIT_API_KEY"])["structuredContent"]
    render_png(chart["imageUrl"])

    if slide_id is None:
        slide_id = add_title_slide(slides, presentation_id, SLIDE_TITLE)
    image_id = insert_chart(slides, presentation_id, slide_id, chart, *centred(box, chart["sizePt"]))

    print(f"Chart {chart['chartId']} is on slide {slide_id} as image {image_id}.")
    print(f"https://docs.google.com/presentation/d/{presentation_id}/edit#slide=id.{slide_id}")


if __name__ == "__main__":
    main()

Run it

Terminalbash
export CHARTKIT_API_KEY=ck_team_...
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
python chartkit_slides.py YOUR_PRESENTATION_ID

The presentation id is the part of the deck URL between /d/ and /edit. The script prints the chart id and a link to the new slide.

Troubleshooting

More causes and fixes are on Errors and limits.