Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Under the Hood
body: Add meta field passthrough to tool definition infra for MCP Apps support
time: 2026-03-04T10:00:00.000000-08:00
55 changes: 55 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# CLAUDE.md

## Project Overview

dbt-mcp is an MCP (Model Context Protocol) server that exposes dbt functionality as tools to AI assistants. Built on `FastMCP` from the `mcp` SDK.

## Key Paths

- Entry point: `src/dbt_mcp/main.py`
- Server: `src/dbt_mcp/mcp/server.py` (`DbtMCP` class, `create_dbt_mcp()`)
- Tool infra: `src/dbt_mcp/tools/` (definitions, registration, injection, toolsets, tool_names)
- Tool categories: `discovery/`, `semantic_layer/`, `dbt_cli/`, `dbt_codegen/`, `dbt_admin/`, `lsp/`, `mcp_server_metadata/`
- Prompts (tool descriptions): `src/dbt_mcp/prompts/`
- Config: `src/dbt_mcp/config/`
- Tests: `tests/unit/`, `tests/integration/`

## Tool Architecture

Tools follow a consistent pattern:
1. `@dbt_mcp_tool` decorator defines the tool with metadata
2. `ToolName` enum in `tools/tool_names.py` — every tool needs an entry
3. Toolset mapping in `tools/toolsets.py` — maps tools to categories
4. Context injection via `adapt_context()` — tools receive typed context objects, but MCP only sees user-facing params
5. `register_tools()` in `tools/register.py` — precedence-based enablement (individual > toolset > default)

### MCP Apps

Tools can have associated UIs via the `meta` field:
- `meta={"ui": {"resourceUri": "ui://dbt-mcp/app-name"}}` on `@dbt_mcp_tool`
- `structured_output=True` required — return type must be `TypedDict`
- Register matching resource with `@dbt_mcp.resource(uri=..., mime_type="text/html;profile=mcp-app")`
- Frontend uses `@modelcontextprotocol/ext-apps` SDK

## Commands

- `task test:unit` — run unit tests
- `task test:integration` — run integration tests (requires dbt Platform credentials)
- `task install` — install dependencies
- `task dev` — run server with streamable-http transport
- `task inspector` — run with MCP Inspector
- `uv run pytest tests/ --ignore=tests/integration -x -q` — quick unit test run

Comment thread
jairus-m marked this conversation as resolved.
## Style

- See `.cursor/rules/python.mdc` for Python conventions
- Import at top of file, type annotations on all functions
- Prefer Pydantic models or dataclasses over dicts
- Use `*` in param lists when adjacent params share a type
- Avoid code in `__init__.py`

## Testing

- `MockFastMCP` in `tests/conftest.py` captures registered tools and their kwargs (including `meta`)
- Tool definition tests: `tests/unit/tools/test_definitions.py`
- Precedence logic tests: `tests/unit/tools/test_precedence.py`
118 changes: 92 additions & 26 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ With [task](https://taskfile.dev/) installed, simply run `task` to see the list
## Setup

1. Clone the repository:

```shell
git clone https://github.com/dbt-labs/dbt-mcp.git
cd dbt-mcp
Expand All @@ -17,10 +18,12 @@ cd dbt-mcp
4. Run `task install`

5. Configure environment variables:
```shell
cp .env.example .env
```
Then edit `.env` with your specific environment variables (see [our docs](https://docs.getdbt.com/docs/dbt-ai/setup-local-mcp) to determine the values).

```shell
cp .env.example .env
```

Then edit `.env` with your specific environment variables (see [our docs](https://docs.getdbt.com/docs/dbt-ai/setup-local-mcp) to determine the values).

6. Run `task client` to chat with dbt MCP in your terminal.

Expand Down Expand Up @@ -85,6 +88,63 @@ Or, if you would like to test with Oauth, use a configuration like this:

For improved debugging, you can set the `DBT_MCP_SERVER_FILE_LOGGING=true` environment variable to log to a `./dbt-mcp.log` file.

## Adding Tools

Tools are defined using the `@dbt_mcp_tool` decorator and registered with the MCP server via `register_tools()`.

### Steps to add a new tool

1. **Add a `ToolName` entry** in `src/dbt_mcp/tools/tool_names.py`
2. **Map it to a toolset** in `src/dbt_mcp/tools/toolsets.py` (e.g., `DISCOVERY`, `SEMANTIC_LAYER`, `SQL`)
3. **Write a prompt file** in `src/dbt_mcp/prompts/<category>/` describing the tool
4. **Define the tool function** in the appropriate `tools.py` module:
```python
@dbt_mcp_tool(
description=get_prompt("category/tool_name"),
title="My Tool",
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
)
async def my_tool(context: MyToolContext, param: str) -> dict:
return await context.fetcher.do_something(param)
```
5. **Add it to the tool list** (e.g., `DISCOVERY_TOOLS`) in the same module
6. The registration function (e.g., `register_discovery_tools`) handles context binding and registration

### Adding an MCP App tool

MCP Apps are tools that have an associated interactive UI rendered by the host. They build on top of regular tools with two additions:
Comment thread
jgiuffrida marked this conversation as resolved.
Outdated

1. **Use `structured_output=True` and `meta`** to link the tool to a UI resource:
Comment thread
jgiuffrida marked this conversation as resolved.
Outdated
```python
@dbt_mcp_tool(
description=get_prompt("category/tool_name"),
title="My Visualization",
read_only_hint=True,
structured_output=True,
meta={"ui": {"resourceUri": "ui://dbt-mcp/my-app"}},
)
async def my_viz_tool(context: MyToolContext, param: str) -> MyResultType:
...
```
The return type must be a `TypedDict` when `structured_output=True`.
Comment thread
jgiuffrida marked this conversation as resolved.
Outdated

2. **Register an MCP resource** at the matching `ui://` URI to serve the HTML app:
```python
@dbt_mcp.resource(
uri="ui://dbt-mcp/my-app",
name="My App",
mime_type="text/html;profile=mcp-app",
)
def get_my_app_ui() -> str:
return Path("packages/my-app/dist/index.html").read_text()
```

3. **Build a frontend** in `packages/` using `@modelcontextprotocol/ext-apps`. The app receives tool results via the `ontoolresult` callback and must be bundled as a single HTML file (e.g., using `vite-plugin-singlefile`) or reference external resources via CSP `resourceDomains`.

The `ui://` URI convention is `ui://<server-name>/<resource-name>`. The `meta` field is passed through the full tool registration pipeline (`@dbt_mcp_tool` → `GenericToolDefinition` → `adapt_context` → `register_tools` → `FastMCP.add_tool`).

## Signed Commits

Before committing changes, ensure that you have set up [signed commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits).
Expand All @@ -99,19 +159,22 @@ Every PR requires a changelog entry. [Install changie](https://changie.dev/) and
The dbt-mcp server runs with `stdio` transport by default which does not allow for Python debugger support. For debugging with breakpoints, use `streamable-http` transport.

### Option 1: MCP Inspector Only (No Breakpoints)

1. Run `task inspector` - this starts both the server and inspector automatically
2. Open MCP Inspector UI
3. Use "STDIO" Transport Type to connect
4. Test tools interactively in the inspector UI (uses `stdio` transport, no debugger support)

### Option 2: VS Code Debugger with Breakpoints (Recommended for Debugging)

1. Set breakpoints in your code
2. Press `F5` or select "debug dbt-mcp" from the Run menu
3. Open MCP Inspector UI via `npx @modelcontextprotocol/inspector`
4. Connect to `http://localhost:8000/mcp/v1` using "Streamable HTTP" transport and "Via Proxy" connection type
5. Call tools from Inspector - your breakpoints will trigger

### Option 3: Manual Debugging with `task dev`

1. Run `task dev` - this starts the server with `streamable-http` transport on `http://localhost:8000`
2. Set breakpoints in your code
3. Attach your debugger manually (see [debugpy documentation](https://github.com/microsoft/debugpy#debugpy) for examples)
Expand All @@ -128,29 +191,32 @@ If you encounter any problems, you can try running `task run` to see errors in y
Only people in the `CODEOWNERS` file should trigger a new release with these steps:

1. Consider these guidelines when choosing a version number:
- Major
- Removing a tool or toolset without replacement, or in a way that would result in agents behaving much differently.
- Changing the behavior of existing environment variables or configurations
- Minor
- Changes to config system related to the function signature of the register functions (e.g. `register_discovery_tools`)
- Adding optional parameters to a tool function signature
- Adding a new tool or toolset
- Removing or adding non-optional parameters from tool function signatures
- Renaming a tool
- Removing a tool which overlaps or provides similar functionality as other tool(s)
- Patch
- Bug and security fixes - only major security and bug fixes will be back-ported to prior minor and major versions
- Dependency updates which don’t change behavior
- Minor enhancements
- Editing a tool or parameter description prompt
- Adding an allowed environment variable with the `DBT_MCP_` prefix

- Major
- Removing a tool or toolset without replacement, or in a way that would result in agents behaving much differently.
- Changing the behavior of existing environment variables or configurations
- Minor
- Changes to config system related to the function signature of the register functions (e.g. `register_discovery_tools`)
- Adding optional parameters to a tool function signature
- Adding a new tool or toolset
- Removing or adding non-optional parameters from tool function signatures
- Renaming a tool
- Removing a tool which overlaps or provides similar functionality as other tool(s)
- Patch
- Bug and security fixes - only major security and bug fixes will be back-ported to prior minor and major versions
- Dependency updates which don’t change behavior
- Minor enhancements
- Editing a tool or parameter description prompt
- Adding an allowed environment variable with the `DBT_MCP_` prefix

2. Trigger the [Create release PR Action](https://github.com/dbt-labs/dbt-mcp/actions/workflows/create-release-pr.yml).
- If the release is NOT a pre-release, select `auto` (default) to automatically determine the version bump based on changelog entries, or manually pick patch, minor or major if needed
- If the release is a pre-release, set the bump and the pre-release suffix. We support alpha.N, beta.N and rc.N.
- use alpha for early releases of experimental features that specific people might want to test. Significant changes can be expected between alpha and the official release.
- use beta for releases that are mostly stable but still in development. It can be used to gather feedback from a group of peopleon how a specific feature should work.
- use rc for releases that are mostly stable and already feature complete. Only bugfixes and minor changes are expected between rc and the official release.
- Picking the prerelease suffix will depend on whether the last release was the stable release or a pre-release:

- If the release is NOT a pre-release, select `auto` (default) to automatically determine the version bump based on changelog entries, or manually pick patch, minor or major if needed
- If the release is a pre-release, set the bump and the pre-release suffix. We support alpha.N, beta.N and rc.N.
- use alpha for early releases of experimental features that specific people might want to test. Significant changes can be expected between alpha and the official release.
- use beta for releases that are mostly stable but still in development. It can be used to gather feedback from a group of peopleon how a specific feature should work.
- use rc for releases that are mostly stable and already feature complete. Only bugfixes and minor changes are expected between rc and the official release.
- Picking the prerelease suffix will depend on whether the last release was the stable release or a pre-release:

| Last Stable | Last Pre-release | Bump | Pre-release Suffix | Resulting Version |
| ----------- | ---------------- | ----- | ------------------ | ----------------- |
Expand Down
5 changes: 5 additions & 0 deletions src/dbt_mcp/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class GenericToolDefinition[NameEnum: Enum]:
# We haven't strictly defined our tool contracts yet.
# So we're setting this to False by default for now.
structured_output: bool | None = False
meta: dict[str, Any] | None = None
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a risk to confuse ourselves/people between the MCP tool meta and the concept of meta in dbt that we already rely on in the MCP server?


def get_name(self) -> NameEnum:
return self.name_enum((self.name or self.fn.__name__).lower())
Expand All @@ -34,6 +35,7 @@ def to_fastmcp_internal_tool(self) -> Tool:
description=self.description,
annotations=self.annotations,
structured_output=self.structured_output,
meta=self.meta,
)

def adapt_context(
Expand All @@ -50,6 +52,7 @@ def adapt_context(
title=self.title,
annotations=self.annotations,
structured_output=self.structured_output,
meta=self.meta,
)


Expand All @@ -68,6 +71,7 @@ def generic_dbt_mcp_tool[NameEnum: Enum](
idempotent_hint: bool = False,
open_world_hint: bool = True,
structured_output: bool | None = False,
meta: dict[str, Any] | None = None,
) -> Callable[[Callable], GenericToolDefinition[NameEnum]]:
"""Decorator to define a tool definition for dbt MCP"""

Expand All @@ -86,6 +90,7 @@ def decorator(fn: Callable) -> GenericToolDefinition[NameEnum]:
openWorldHint=open_world_hint,
),
structured_output=structured_output,
meta=meta,
)

return decorator
Expand Down
1 change: 1 addition & 0 deletions src/dbt_mcp/tools/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,5 @@ def generic_register_tools[NameEnum: Enum](
description=tool_definition.description,
annotations=tool_definition.annotations,
structured_output=tool_definition.structured_output,
meta=tool_definition.meta,
)
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ def path(relpath: str):
class MockFastMCP:
def __init__(self):
self.tools = {}
self.tool_kwargs = {}

def add_tool(self, fn: Callable[..., Any], **kwargs):
self.tools[fn.__name__] = fn
self.tool_kwargs[fn.__name__] = kwargs

def tool(self, **kwargs):
def decorator(func):
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/tools/test_definitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Unit tests for tool definition infrastructure."""

from enum import Enum
from typing import Any

from dbt_mcp.tools.definitions import GenericToolDefinition, generic_dbt_mcp_tool
from dbt_mcp.tools.register import generic_register_tools
from dbt_mcp.tools.toolsets import Toolset


class FakeToolName(Enum):
MY_TOOL = "my_tool"


def _make_tool(
meta: dict[str, Any] | None = None,
) -> GenericToolDefinition[FakeToolName]:
"""Helper to create a tool definition with optional meta."""

@generic_dbt_mcp_tool(
description="test tool",
name_enum=FakeToolName,
title="Test Tool",
read_only_hint=True,
meta=meta,
)
async def my_tool() -> str:
return "ok"

return my_tool


class TestMetaPassthrough:
"""Test that the meta field is preserved through all operations."""

def test_decorator_sets_meta(self):
meta = {"ui": {"resourceUri": "ui://test/app.html"}}
tool = _make_tool(meta=meta)
assert tool.meta == meta

def test_decorator_meta_defaults_to_none(self):
tool = _make_tool()
assert tool.meta is None

def test_adapt_context_preserves_meta(self):
meta = {"ui": {"resourceUri": "ui://test/app.html"}}
tool = _make_tool(meta=meta)

def mapper() -> None:
return None

adapted = tool.adapt_context(mapper)
assert adapted.meta == meta

def test_to_fastmcp_internal_tool_passes_meta(self):
meta = {"ui": {"resourceUri": "ui://test/app.html"}}
tool = _make_tool(meta=meta)

internal = tool.to_fastmcp_internal_tool()
assert internal.meta == meta

def test_to_fastmcp_internal_tool_none_meta(self):
tool = _make_tool()

internal = tool.to_fastmcp_internal_tool()
assert internal.meta is None

def test_register_tools_passes_meta(self, mock_fastmcp):
mock_mcp, _ = mock_fastmcp
meta = {"ui": {"resourceUri": "ui://test/app.html"}}
tool = _make_tool(meta=meta)

generic_register_tools(
mock_mcp,
[tool],
disabled_tools=set(),
enabled_tools=None,
enabled_toolsets=set(),
disabled_toolsets=set(),
tool_to_toolset={FakeToolName.MY_TOOL: Toolset.DISCOVERY},
)

assert mock_mcp.tool_kwargs["my_tool"]["meta"] == meta

def test_register_tools_passes_none_meta(self, mock_fastmcp):
mock_mcp, _ = mock_fastmcp
tool = _make_tool()

generic_register_tools(
mock_mcp,
[tool],
disabled_tools=set(),
enabled_tools=None,
enabled_toolsets=set(),
disabled_toolsets=set(),
tool_to_toolset={FakeToolName.MY_TOOL: Toolset.DISCOVERY},
)

assert mock_mcp.tool_kwargs["my_tool"]["meta"] is None
Loading