Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions .github/workflows/rill-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ jobs:
with:
go-version: 1.26.5

- name: Set up NodeJS
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Build and embed the MCP Apps chart widget
run: |-
npm ci
npm run build:i18n
make runtime.mcpapp.embed

- name: Login to GCR
uses: docker/login-action@v3
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/web-test-code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- "web-admin/**"
- "web-common/**"
- "web-local/**"
- "web-mcp-app/**"
jobs:
build:
runs-on: ubuntu-22.04
Expand All @@ -30,6 +31,10 @@ jobs:
common:
- ".github/workflows/web-test.yml"
- "web-common/**"
mcpapp:
- ".github/workflows/web-test.yml"
- "web-common/**"
- "web-mcp-app/**"

- name: Set up NodeJS
uses: actions/setup-node@v4
Expand All @@ -43,3 +48,4 @@ jobs:
ADMIN: ${{ steps.filter.outputs.admin }}
LOCAL: ${{ steps.filter.outputs.local }}
COMMON: ${{ steps.filter.outputs.common }}
MCPAPP: ${{ steps.filter.outputs.mcpapp }}
24 changes: 24 additions & 0 deletions .github/workflows/web-test-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- "web-admin/**"
- "web-common/**"
- "web-local/**"
- "web-mcp-app/**"
jobs:
build:
runs-on: ubuntu-22.04
Expand All @@ -34,6 +35,13 @@ jobs:
- "package.json"
- "package-lock.json"
- "web-common/**"
mcpapp:
- ".github/workflows/web-test-unit-tests.yml"
- ".nvmrc"
- "package.json"
- "package-lock.json"
- "web-common/**"
- "web-mcp-app/**"

- name: Set up NodeJS
uses: actions/setup-node@v4
Expand All @@ -57,3 +65,19 @@ jobs:
- name: Run web-admin unit tests
if: steps.filter.outputs.admin == 'true'
run: npm run test:unit -w web-admin

- name: Build the MCP App widget
if: steps.filter.outputs.mcpapp == 'true'
run: npm run build -w web-mcp-app

- name: Run MCP App widget unit tests
if: steps.filter.outputs.mcpapp == 'true'
run: npm run test -w web-mcp-app

- name: Install Playwright browsers for the MCP App widget
if: steps.filter.outputs.mcpapp == 'true'
run: npx playwright install --with-deps chromium

- name: Run MCP App widget browser tests
if: steps.filter.outputs.mcpapp == 'true'
run: npm run test:e2e -w web-mcp-app
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ node_modules
/cli/pkg/examples/embed/dist
/cli/pkg/installscript/embed/install.sh
/cli/pkg/web/embed/dist
/runtime/ai/mcpapp/embed/widget.html
/web-mcp-app/dist/
/runtime/drivers/duckdb/extensions/embed/*/*
!/runtime/drivers/duckdb/extensions/embed/**/.gitkeep
/dev-project*
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ cli.prepare: runtime.examples.embed
rm -rf cli/pkg/web/embed/dist || true
mkdir -p cli/pkg/web/embed/dist
cp -r web-local/build/* cli/pkg/web/embed/dist
$(MAKE) runtime.mcpapp.embed
go run scripts/embed_duckdb_ext/main.go

.PHONY: coverage.go
Expand Down Expand Up @@ -57,6 +58,15 @@ proto.generate:
npm run generate:runtime-client -w web-common
npm run generate:client -w web-admin

# Builds the MCP Apps chart widget and copies it where the runtime embeds it (see runtime/ai/mcpapp/embed/README.md).
# Requires the npm dependencies to be installed and the i18n messages to be compiled (npm run build:i18n).
# The widget is optional: without the file, the runtime serves its MCP tools without the widget.
.PHONY: runtime.mcpapp.embed
runtime.mcpapp.embed:
npm run build -w web-mcp-app
mkdir -p runtime/ai/mcpapp/embed
cp web-mcp-app/dist/widget.html runtime/ai/mcpapp/embed/widget.html

KEEP_EXAMPLES := rill-openrtb-prog-ads rill-github-analytics rill-cost-monitoring

.PHONY: runtime.examples.embed
Expand Down
31 changes: 29 additions & 2 deletions admin/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/rilldata/rill/admin/database"
"github.com/rilldata/rill/admin/server/auth"
"github.com/rilldata/rill/runtime/ai"
"github.com/rilldata/rill/runtime/ai/mcpapp"
"github.com/rilldata/rill/runtime/pkg/httputil"
"github.com/rilldata/rill/runtime/pkg/middleware"
"github.com/rilldata/rill/runtime/pkg/observability"
Expand Down Expand Up @@ -48,11 +49,15 @@ const (
// mcpForwardedTools are the runtime tools exposed on the MCP server.
// The runtime exposes more tools than these, but the rest require runtime.EditRepo,
// which is only granted for editable deployments, and a project's primary deployment is never editable.
// create_chart is always exposed: clients that do not get the MCP Apps widget receive its markdown fallback.
// chart_data may only be called by the widget, so it is only exposed when this server serves the widget (see mcpForwardedToolSpecs).
var mcpForwardedTools = []string{
ai.ListMetricsViewsName,
ai.GetMetricsViewName,
ai.QueryMetricsViewSummaryName,
ai.QueryMetricsViewName,
ai.CreateChartName,
ai.ChartDataName,
ai.ListSkillsName,
ai.LoadSkillName,
}
Expand All @@ -66,8 +71,14 @@ var mcpSessionNamespace = uuid.MustParse("6f4a1c94-3d1e-4f6b-9a02-1c0f6b7d5e8a")
// Unlike the per-project runtime proxy, it serves a single endpoint for all the projects a caller has access to:
// it answers the MCP protocol methods itself and forwards tool calls to the runtime of the project named in the tool arguments.
func (s *Server) mcpHandler() (http.Handler, error) {
// The MCP Apps widget is served whenever the binary carries the widget bundle (see runtime/ai/mcpapp).
widget, ok := mcpapp.Widget()
if !ok {
s.logger.Warn("mcp: the MCP Apps widget bundle is not embedded in this binary; serving the chart tools without the widget")
}

adminTools := s.mcpAdminTools()
forwardedTools, err := mcpForwardedToolSpecs()
forwardedTools, err := mcpForwardedToolSpecs(widget)
if err != nil {
return nil, err
}
Expand All @@ -79,6 +90,11 @@ func (s *Server) mcpHandler() (http.Handler, error) {
}
}

var capabilities *mcp.ServerCapabilities
if widget != nil {
capabilities = mcpapp.ServerCapabilities()
}

return middleware.MCPSessionIDMiddleware(mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
srv := mcp.NewServer(
&mcp.Implementation{
Expand All @@ -89,6 +105,7 @@ func (s *Server) mcpHandler() (http.Handler, error) {
&mcp.ServerOptions{
Instructions: mcpInstructions,
HasTools: true,
Capabilities: capabilities,
},
)

Expand All @@ -111,6 +128,9 @@ func (s *Server) mcpHandler() (http.Handler, error) {
for _, t := range forwardedTools {
srv.AddTool(t, s.mcpForwardToolCall(t.Name, client))
}
if widget != nil {
mcpapp.Register(srv, widget)
}

return srv
}, &mcp.StreamableHTTPOptions{
Expand Down Expand Up @@ -162,7 +182,9 @@ type mcpAdminTool struct {

// mcpForwardedToolSpecs returns the specs of the tools forwarded to a project's runtime, using the tool definitions in runtime/ai as the source of truth.
// It injects the "project" argument into the input schema of each tool, which we intercept to route calls to the correct runtime.
func mcpForwardedToolSpecs() ([]*mcp.Tool, error) {
// The tools that take part in the MCP Apps widget get the matching _meta.ui: create_chart links to the widget when it is served,
// and app-only tools, which only the widget may call, declare their visibility and are left out when it is not served.
func mcpForwardedToolSpecs(widget *mcpapp.Asset) ([]*mcp.Tool, error) {
specs := ai.MCPToolSpecs()

res := make([]*mcp.Tool, 0, len(mcpForwardedTools))
Expand All @@ -172,6 +194,11 @@ func mcpForwardedToolSpecs() ([]*mcp.Tool, error) {
return nil, fmt.Errorf("mcp: no spec for tool %q", name)
}

if widget == nil && ai.IsMCPAppOnlyTool(name) {
continue
}
spec = mcpapp.ToolWithMeta(spec, ai.MCPAppToolMeta(name, widget))

// Inject the "project" argument into the input schema.
schema, ok := spec.InputSchema.(*jsonschema.Schema)
if !ok {
Expand Down
69 changes: 67 additions & 2 deletions admin/server/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

Expand All @@ -15,26 +17,89 @@ import (
"github.com/rilldata/rill/admin"
"github.com/rilldata/rill/admin/database"
"github.com/rilldata/rill/runtime/ai"
"github.com/rilldata/rill/runtime/ai/mcpapp"
"github.com/stretchr/testify/require"
)

func TestMCPForwardedToolSpecs(t *testing.T) {
tools, err := mcpForwardedToolSpecs()
// Without the widget, create_chart is still forwarded (clients get its text fallback) but chart_data, which only the widget
// may call, is left out; no tool carries UI metadata.
tools, err := mcpForwardedToolSpecs(nil)
require.NoError(t, err)
require.Len(t, tools, len(mcpForwardedTools))
require.Len(t, tools, len(mcpForwardedTools)-1)

byName := make(map[string]*mcp.Tool)
for _, tool := range tools {
byName[tool.Name] = tool
schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "tool %q", tool.Name)
require.Contains(t, schema.Properties, mcpProjectArg, "tool %q", tool.Name)
require.Contains(t, schema.Required, mcpProjectArg, "tool %q", tool.Name)
require.Nil(t, tool.Meta["ui"], "tool %q", tool.Name)

// query_metrics_view has a hand-written schema, which must be extended and not replaced.
if tool.Name == ai.QueryMetricsViewName {
require.Contains(t, schema.Properties, "metrics_view")
require.NotEmpty(t, schema.Defs)
}
}
require.Contains(t, byName, ai.CreateChartName)
require.Equal(t, "Creating chart...", byName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"], "the tool's own meta is kept")
require.NotContains(t, byName, ai.ChartDataName)
require.Contains(t, byName, ai.QueryMetricsViewName)

// With the widget, create_chart links to it and chart_data is restricted to it.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "widget.html"), []byte("<html></html>"), 0o644))
t.Setenv(mcpapp.DirEnv, dir)
widget, ok := mcpapp.Widget()
require.True(t, ok)

tools, err = mcpForwardedToolSpecs(widget)
require.NoError(t, err)
require.Len(t, tools, len(mcpForwardedTools))
byName = make(map[string]*mcp.Tool)
for _, tool := range tools {
byName[tool.Name] = tool
}
require.Equal(t, widget.URI, byName[ai.CreateChartName].Meta["ui"].(map[string]any)["resourceUri"])
require.Equal(t, "Creating chart...", byName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"], "existing meta is kept")
require.Equal(t, []any{"app"}, byName[ai.ChartDataName].Meta["ui"].(map[string]any)["visibility"])
require.Nil(t, byName[ai.QueryMetricsViewName].Meta["ui"])
require.Contains(t, byName[ai.CreateChartName].InputSchema.(*jsonschema.Schema).Properties, mcpProjectArg)
}

// TestCallRuntimeToolPassthrough verifies that the content blocks, structured content and _meta a runtime tool returns
// reach the client unchanged, which the MCP Apps widget relies on.
func TestCallRuntimeToolPassthrough(t *testing.T) {
type args struct {
Name string `json:"name"`
}
type result struct {
Name string `json:"name"`
}
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
srv := mcp.NewServer(&mcp.Implementation{Name: "runtime"}, &mcp.ServerOptions{HasTools: true})
mcp.AddTool(srv, &mcp.Tool{Name: "echo"}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, result, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "**Chart: " + a.Name + "**"}},
Meta: map[string]any{"rill/chart": map[string]any{"rows": float64(1)}},
}, result{Name: a.Name}, nil
})
return srv
}, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
rt := httptest.NewServer(handler)
t.Cleanup(rt.Close)

s := &Server{}
depl := &database.Deployment{RuntimeHost: rt.URL, RuntimeInstanceID: "default"}
res, err := s.callRuntimeTool(t.Context(), depl, "jwt", &mcpClient{sessionID: "session", userAgent: "claude-ai"}, "echo", json.RawMessage(`{"name":"hello"}`))
require.NoError(t, err)
require.False(t, res.IsError)
require.Len(t, res.Content, 1)
require.Equal(t, "**Chart: hello**", res.Content[0].(*mcp.TextContent).Text)
require.Equal(t, map[string]any{"name": "hello"}, res.StructuredContent)
require.Equal(t, map[string]any{"rill/chart": map[string]any{"rows": float64(1)}}, map[string]any(res.Meta))
}

func TestTakeMCPProjectArg(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/developers/build/ai-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Feature flags support the same templating as other project settings, so memory c

## Visualization Tips

When using the [Rill MCP Server](/guide/ai/mcp) with external AI clients like Claude, you can provide specific instructions on how to visualize data. Since the MCP server returns structured data, the AI client is responsible for rendering it.
When using the [Rill MCP Server](/guide/ai/mcp) with external AI clients, how results are visualized depends on the client. Clients that support MCP Apps, such as Claude and ChatGPT, render Rill's own interactive charts inline when the AI calls `create_chart`; see [Interactive charts in Claude and ChatGPT](/guide/ai/mcp#interactive-charts-in-claude-and-chatgpt). The tips below only apply to text-only clients, which receive structured data and leave the rendering to the AI, so you can tell it how to present results as text.

:::note Visualization tips affect all AI interactions
Visualization instructions added to `rill.yaml` will affect both [Rill Chat](/guide/ai/ai-chat) responses and external AI clients via the MCP Server. If you only want visualization tips to apply to external AI clients (like Claude Desktop), consider adding them to your client-specific configuration files instead:
Expand Down
25 changes: 23 additions & 2 deletions docs/docs/guide/ai/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Replace `org` and `project` with the ID of your organization and project.
"command": "npx",
"args": [
"mcp-remote",
"https://api.rilldata.com/v1/organizations/{org}/projects/{project}/runtime/mcp",
"https://api.rilldata.com/v1/orgs/{org}/projects/{project}/runtime/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
Expand All @@ -202,7 +202,7 @@ See [our demo page](https://ui.rilldata.com/demo) for public projects to test.
"command": "npx",
"args": [
"mcp-remote",
"https://api.rilldata.com/v1/organizations/demo/projects/rill-github-analytics/runtime/mcp"
"https://api.rilldata.com/v1/orgs/demo/projects/rill-github-analytics/runtime/mcp"
]
}
}
Expand Down Expand Up @@ -260,6 +260,8 @@ You can look at one of our [example projects](https://github.com/rilldata/rill-e
- __*Get metrics view spec*__ – Use `get_metrics_view` to fetch a metrics view's specification. This is important to understand all the dimensions and measures in a metrics view.
- __*Query the time range*__ – Use `query_metrics_view_summary` to obtain the available time range for a metrics view. This is important to understand what time range the data spans.
- __*Query the metrics*__ – Use `query_metrics_view` to run queries to get aggregated results.
- __*Create a chart*__ – Use `create_chart` to draw a chart of a metrics view query. Every client receives the chart's data as a table with an **Open in Rill** link; clients that support MCP Apps show it inline as an interactive Rill chart when the chart app is enabled. See [Interactive charts in Claude and ChatGPT](#interactive-charts-in-claude-and-chatgpt).
- __*Load chart data*__ – `chart_data` is called by the interactive chart to refetch its data when you change its time grain, limits or comparison. It is not meant to be called directly, and clients that support MCP Apps hide it from the model.
- __*List skills*__ – Use `list_skills` to discover the [skills](/developers/build/ai-configuration#skills) defined in the project. It returns an empty list when the project defines no skills.
- __*Load a skill*__ – Use `load_skill` to fetch a skill's full instructions before doing work its description covers.

Expand All @@ -277,6 +279,25 @@ Using all the above concepts, you can ask the Rill MCP server questions like:
- In the *last 7 days*, how many __auction requests were there from mobile vs desktop__?


## Interactive charts in Claude and ChatGPT

The server ships a `rill-charts` skill that tells the AI which chart fits which question and what to check before charting; external clients receive it in the `ai_instructions` of `list_metrics_views` and can also fetch it with `load_skill`. When the AI calls `create_chart`, the Rill MCP server answers with the chart's data. Every client receives it as a markdown table of the first rows with an **Open in Rill** link, which opens an Explore dashboard with the chart's measures, dimensions, filters and time range applied. Charts require the `chat_charts` feature flag, which is on by default.

The AI describes the chart the way [custom viz components](/developers/build/dashboards/canvas-widgets/custom-viz) do: a chart type and which field goes on which channel (`chart_spec`, with Flint's chart types such as `Bar Chart`, `Line Chart`, `Waterfall Chart`, `Slope Chart` or `Heatmap`) together with the metrics view, time range, filter and grain the data comes from (`data`). Rill derives the scales, axes, number formats, sorting and layout from the metrics view's semantics, so the AI never writes styling.

Clients that support [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps), such as Claude (web, desktop and mobile), ChatGPT, Cursor and VS Code, can instead show the chart inline in the conversation as an interactive Rill chart, with the same rendering, formatting and theme as custom viz components in Rill. In fullscreen, the chart offers data controls (time grain, top values, comparison with the previous period, each re-queried from Rill) and presentation controls (chart type, layout and chart properties, applied instantly), and tells the AI what it currently shows. Every Rill MCP endpoint serves the chart app whenever charts are enabled: project endpoints, the unified `https://api.rilldata.com/v1/mcp` endpoint and Rill Developer alike, with no configuration of its own. Clients that don't support MCP Apps, such as Claude Code, keep receiving the table and the link.

:::tip Troubleshooting
If charts show up as a table, check whether the server advertises the chart app with the MCP Inspector:

```bash
npx @modelcontextprotocol/inspector --cli <rill-mcp-url> --transport http --method resources/list
```

The output should list a `ui://rill/widget/...` resource. If it is missing, charts are disabled for the project (`chat_charts: false` under `features` in `rill.yaml`) or the deployment hasn't picked up a release that ships the chart app yet. If it is listed but your client still shows a table, the client doesn't support MCP Apps.
:::


## Conclusion
While [Explore dashboards](/guide/dashboards/explore) are a great way to slice and dice to find insights, sometimes you just need a quick, overall summary of your data via a text conversation. The Rill MCP server enables this through external AI assistants like Claude Desktop. Since Rill MCP is built on top of your existing metrics, you can be confident that the returned data will be correct.

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export default [
"web-local/build/*",
"web-local/playwright-report/*",
"web-local/playwright/*",
"web-mcp-app/dist/*",
"web-mcp-app/playwright-report/*",
"web-mcp-app/test-results/*",
],
},
{
Expand Down
Loading
Loading