Creating an AI Wrapper Around VizQL Data Service (VDS)

MCP Servers have made it easy to connect AI agents to external tools and data sources. But easy access doesn't mean accurate results. When I wired up Tableau's MCP Server to an AI agent, the agent could query my datasource — it just couldn't do it reliably. Slight ambiguity or even a typo in the query led to incorrect or no results.

The issue was that that the agent had no knowledge of the data before constructing the query.

To fix this, I needed more control over a specific step in the workflow — enough to force the agent to look before it leaped. That's where LangChain came in. Rather than a full SDK or a rigid pipeline, LangChain gave me a lightweight way to wrap one critical step with its own prompt and its own model call. In this post, I walk through how I built that wrapper around Tableau's VizQL Data Service — and why it worked.

The Problem

I wanted to build an AI-infused workflow using Tableau's MCP Server. The idea was to expose the MCP Server's query-datasource tool to an agent to field natural language (NL) queries. As I was experimenting with the tool, I noticed that the agent often mis-interpreted the user prompt or mapped user questions to the wrong data fields. For example, I may ask the agent for sales in the EU region. In the data, however, EU may show up as "European Union (EU)." The agent, not knowing this, would just try to incorrectly query for "EU." In other instances, the agent filled in ambiguities in the query with plausible-sounding guesses. This is where I needed to fine-tune the workflow to help AI do three things in order before the execution of the query-datasource tool:

  1. Fetch available fields in the data
  2. Fetch metatada related to those fields
  3. Use the above two to construct the VizQL query that feeds into the query-datasource tool

The Setup

For context, I'll take a short detour to explain the overall setup of my project. I had built this workflow to be a full data pipeline that encompasses data ingestion, transformation, and data exploration. There is one LangChain agent that acts as an orchestrator, to which I expose tools with a prompt on how/when to execute those tools.

In the beginning, I didn't expose query-datasource as a separate tool. Instead, I instructed the agent to use the tool from Tableau's MCP Server in the system prompt. This is where I noticed that the agent wasn't good at resolving query ambiguities because it was not explicitly using its knowledge of the data to build the query. To solve for this, I created an AI wrapper around this step to force the agent to fetch metadata at runtime.

The Solution

I decided to spawn a sub-chain to handle this process to partially isolate it from the main process.

The sub-chain was provided with the below prompt:

    [
        (
            "system", """You are helping to build a VizQL query for the ACEA 
            automotive registration dataset. Given a natural-language question, 
            output a JSON object with structure as outlined in tool schema:

{schema}

Available fields:{fieldsmetadata}

Distinct values for each dimension (use these EXACT strings in filters):

{dimensionvalues}

Output ONLY the raw JSON object. No markdown, no explanation."""),
    ("user", "{question}"),
    ]
    )```

The output is the JSON VizQL query. To build the query, the agent relies on the following information:

  1. the schema of the tool
  2. fields metadata (e.g., what fields are available? what are their data types?)
  3. available values in dimension values

Extracting the Tool Schema

MCP Server tools come with schemas that serve as instructions for LLM's. For my sub-chain to properly build the query, I had to provide it with instructions, or the schema found in the query-datasource tool. At runtime, I pass in the schema by executing the below function:


  async with stdio_client(params) as (read, write):
      async with ClientSession(read, write) as session:
          await session.initialize()
          all_tools = await load_mcp_tools(session)
          tool = next(t for t in all_tools if t.name == "query-datasource")
          schema = tool.args_schema.get("properties", {}).get("query",
          tool.args_schema)

This functions returns the schema of the tool, which I then pass into my prompt as a parameter.

Fetching the Fields Metadata and Available Values

To fetch metadata and to expose available values in those fields, I used the VDS API directly.

def _read_metadata(server_url: str, token: str, luid: str) -> list[dict]:
    """
    Call POST /api/v1/vizql-data-service/read-metadata.

    """
    url = f"{server_url}/api/v1/vizql-data-service/read-metadata"
    resp = requests.post(
        url,
        json={"datasource": {"datasourceLuid": luid}},
        headers={"X-Tableau-Auth": token},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("data", [])

This function uses the VDS read-metadata endpoint to return a list of field metadata, including fieldCaption and datatype.

The fields returned from the _read_metadata function is then passed into the function below to retrieve available values from the fields.

def _read_dimension_values(server_url: str, token: str, luid: str, field_caption: str = 'REGION')\
    -> list:
    """Return distinct values for a single dimension field"""

    query = {"fields": [{"fieldCaption": field_caption}]}

    rows = _query_datasource(server_url, token, luid, query)

    distinct_values = [row.get(field_caption) for row in rows]

    return distinct_values

Putting It All Together

The final step is to house all of these components together under one public function, which is provided to my main agent.

async def _build_vizql_query(nl_query: str, fields_metadata: list, dimension_values: dict) -> str:
    """Use Claude Haiku to translate a natural-language query into a VizQL query dict."""
    fields_str = "\n".join(
        f"  {f.get('fieldCaption')} — {f.get('dataType', '?')} ({f.get('columnClass', '?')})"
        for f in fields_metadata if f.get("fieldCaption")
    )  
    values_str = "\n".join(
        f"  {field}: {values}" for field, values in dimension_values.items()
    )

    schema = await fetch_tool_json()

    llm = ChatAnthropic(model="claude-haiku-4-5-20251001", temperature=0)
    chain = _NL_TO_VIZQL_PROMPT | llm
    response = await chain.ainvoke({
        "question": nl_query,
        "fields_metadata": fields_str,
        "dimension_values": values_str,
        "schema": schema
    })
    text = response.content if hasattr(response, "content") else str(response)
    text = text.strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
    return text

The function above is one level below the public entry point but is where my various steps are brought together. The LLM I decided to use for this relatively light task was Claude's Haiku. I execute my individual functions to fetch the tool schema and fields metadata. I pass those into the LLM by using LangChain's chain.ainvoke syntax (ainvoke for async, just invoke for non-async).

The response of the LLM is the VDS query in raw JSON format. This is the query that the main agent ultimately picks up to use in the query-datasource tool.

Some Considerations And Takeaways

Some of the methods I employed in solving this problem may feel contrived. I wanted to test the various ways I could set this up and really practice using LangChain. This approach may have led to redundant architecture.

For example, a better approach may have been to avoid creating a sub-chain just for this process but expose the function directly to my main agent as a helper. Instead of returning the full VDS query, the helper function could have returned instructions or a refined NL query for the main agent to pass into the query-datasource tool. This would have also made is unnecessary to explicitly provide the schema of the tool, as the schema would have been exposed to the LLM natively.

Overall, however, I noticed much more accurate results from my NL queries. Even with typos or ambiguous queries, the agent was able to map key words of the query to existing fields and values in the data.

It was an interesting way to tackle some of the problems I see with AI implementation - LLMs can feel like a black box and make it difficult to trace and debug when things go wrong. By breaking up a task into smaller steps using AI wrappers like LangChain, we can isolate, refine, and calibrate critical components of our workflow for traceability and consistency.

Check out the full repo here.

Author:
Charles Yi
Powered by The Information Lab
1st Floor, 25 Watling Street, London, EC4M 9BR
Subscribe
to our Newsletter
Get the lastest news about The Data School and application tips
Subscribe now
© 2026 The Information Lab