feat: adding generate embed url functionality (#1877)

## Description

> Should include a concise description of the changes (bug or feature),
it's
> impact, along with a summary of the solution

This PR adds a new tool to the Looker MCP Toolbox, that enables the user
authenticated to the Looker Source to generate authenticated embed urls
for dashboards, looks and queries. When combined with other tools that
already exist in the Looker toolbox, this enables searching existing
content and providing authenticated urls to them OR creating
authenticated urls from queries generated via Natural Language. The
embed urls will create an embed session for the user and can be opened
directly or added to an iframe `src` attribute to provide a smooth
Embedded Analytics setup.

Additionally this PR adds a new optional parameter to the Looker source
called `SessionLength` which an admin setting up the Looker source can
set to determine how long the Embed sessions last for.

## PR Checklist

> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:

- [ X] Make sure you reviewed

[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [ X] Make sure to open an issue as a

[bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose)
  before writing your code! That way we can discuss the change, evaluate
  designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ X] Code coverage does not decrease (if any source code was changed)
- [X ] Appropriate docs were updated (if necessary)
- [ X] Make sure to add `!` if this involve a breaking change

🛠️ Fixes #1876 https://github.com/googleapis/genai-toolbox/issues/1876

---------

Co-authored-by: Luka Fontanilla <maluka@google.com>
Co-authored-by: Dr. Strangelove <drstrangelove@google.com>
This commit is contained in:
Luka Fontanilla
2025-11-13 11:15:39 -08:00
committed by GitHub
parent a89191d8bb
commit ef63860559
7 changed files with 376 additions and 0 deletions

View File

@@ -122,6 +122,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectfile"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteprojectfile"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdevmode"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergenerateembedurl"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnectiondatabases"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnections"
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnectionschemas"

View File

@@ -0,0 +1,47 @@
---
title: "looker-generate-embed-url"
type: docs
weight: 1
description: >
"looker-generate-embed-url" generates an embeddable URL for Looker content.
aliases:
- /resources/tools/looker-generate-embed-url
---
## About
The `looker-generate-embed-url` tool generates an embeddable URL for a given piece of Looker content. The url generated is created for the user authenticated to the Looker source. When opened in the browser it will create a Looker Embed session.
It's compatible with the following sources:
- [looker](../../sources/looker.md)
`looker-generate-embed-url` takes two parameters:
1. the `type` of content (e.g., "dashboards", "looks", "query-visualization")
2. the `id` of the content
It's recommended to use other tools from the Looker MCP toolbox with this tool to do things like fetch dashboard id's, generate a query, etc that can be supplied to this tool.
## Example
```yaml
tools:
generate_embed_url:
kind: looker-generate-embed-url
source: looker-source
description: |
generate_embed_url Tool
This tool generates an embeddable URL for Looker content.
You need to provide the type of content (e.g., 'dashboards', 'looks', 'query-visualization')
and the ID of the content.
```
## Reference
| **field** | **type** | **required** | **description** |
|-------------|:--------:|:------------:|----------------------------------------------------|
| kind | string | true | Must be "looker-generate-embed-url" |
| source | string | true | Name of the source the SQL should execute on. |
| description | string | true | Description of the tool that is passed to the LLM. |

View File

@@ -51,6 +51,7 @@ func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources
ShowHiddenExplores: true,
ShowHiddenFields: true,
Location: "us",
SessionLength: 1200,
} // Default Ssl,timeout, ShowHidden
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
@@ -72,6 +73,7 @@ type Config struct {
ShowHiddenFields bool `yaml:"show_hidden_fields"`
Project string `yaml:"project"`
Location string `yaml:"location"`
SessionLength int64 `yaml:"sessionLength"`
}
func (r Config) SourceConfigKind() string {
@@ -123,6 +125,7 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
Project: r.Project,
Location: r.Location,
TokenSource: tokenSource,
SessionLength: r.SessionLength,
}
if !r.UseClientOAuth {
@@ -156,6 +159,7 @@ type Source struct {
Project string `yaml:"project"`
Location string `yaml:"location"`
TokenSource oauth2.TokenSource
SessionLength int64
}
func (s *Source) SourceKind() string {

View File

@@ -55,6 +55,7 @@ func TestParseFromYamlLooker(t *testing.T) {
ShowHiddenExplores: true,
ShowHiddenFields: true,
Location: "us",
SessionLength: 1200,
},
},
},

View File

@@ -0,0 +1,177 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package lookergenerateembedurl
import (
"context"
"fmt"
yaml "github.com/goccy/go-yaml"
"github.com/googleapis/genai-toolbox/internal/sources"
lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker"
"github.com/googleapis/genai-toolbox/internal/tools"
"github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon"
"github.com/googleapis/genai-toolbox/internal/util"
"github.com/looker-open-source/sdk-codegen/go/rtl"
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
)
const kind string = "looker-generate-embed-url"
func init() {
if !tools.Register(kind, newConfig) {
panic(fmt.Sprintf("tool kind %q already registered", kind))
}
}
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
actual := Config{Name: name}
if err := decoder.DecodeContext(ctx, &actual); err != nil {
return nil, err
}
return actual, nil
}
type Config struct {
Name string `yaml:"name" validate:"required"`
Kind string `yaml:"kind" validate:"required"`
Source string `yaml:"source" validate:"required"`
Description string `yaml:"description" validate:"required"`
AuthRequired []string `yaml:"authRequired"`
}
// validate interface
var _ tools.ToolConfig = Config{}
func (cfg Config) ToolConfigKind() string {
return kind
}
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
// verify source exists
rawS, ok := srcs[cfg.Source]
if !ok {
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
}
// verify the source is compatible
s, ok := rawS.(*lookersrc.Source)
if !ok {
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind)
}
typeParameter := tools.NewStringParameterWithDefault("type", "", "Type of Looker content to embed (ie. dashboards, looks, query-visualization)")
idParameter := tools.NewStringParameterWithDefault("id", "", "The ID of the content to embed.")
parameters := tools.Parameters{
typeParameter,
idParameter,
}
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters)
// finish tool setup
return Tool{
Name: cfg.Name,
Kind: kind,
Parameters: parameters,
AuthRequired: cfg.AuthRequired,
UseClientOAuth: s.UseClientOAuth,
Client: s.Client,
ApiSettings: s.ApiSettings,
manifest: tools.Manifest{
Description: cfg.Description,
Parameters: parameters.Manifest(),
AuthRequired: cfg.AuthRequired,
},
mcpManifest: mcpManifest,
SessionLength: s.SessionLength,
}, nil
}
// validate interface
var _ tools.Tool = Tool{}
type Tool struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
UseClientOAuth bool
Client *v4.LookerSDK
ApiSettings *rtl.ApiSettings
AuthRequired []string `yaml:"authRequired"`
Parameters tools.Parameters
manifest tools.Manifest
mcpManifest tools.McpManifest
SessionLength int64
}
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
logger, err := util.LoggerFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("unable to get logger from ctx: %s", err)
}
paramsMap := params.AsMap()
embedType := paramsMap["type"].(string)
embedType_ptr := &embedType
if *embedType_ptr == "" {
embedType_ptr = nil
}
contentId := paramsMap["id"].(string)
contentId_ptr := &contentId
if *contentId_ptr == "" {
contentId_ptr = nil
}
sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken)
if err != nil {
return nil, fmt.Errorf("error getting sdk: %w", err)
}
forceLogoutLogin := true
req := v4.EmbedParams{
TargetUrl: fmt.Sprintf("%s/embed/%s/%s", t.ApiSettings.BaseUrl, *embedType_ptr, *contentId_ptr),
SessionLength: &t.SessionLength,
ForceLogoutLogin: &forceLogoutLogin,
}
logger.ErrorContext(ctx, "Making request %v", req)
resp, err := sdk.CreateEmbedUrlAsMe(req, nil)
if err != nil {
return nil, fmt.Errorf("error making create_embed_url_as_me request: %s", err)
}
logger.ErrorContext(ctx, "Got response %v", resp)
return resp, nil
}
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
return tools.ParseParams(t.Parameters, data, claims)
}
func (t Tool) Manifest() tools.Manifest {
return t.manifest
}
func (t Tool) McpManifest() tools.McpManifest {
return t.mcpManifest
}
func (t Tool) Authorized(verifiedAuthServices []string) bool {
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
}
func (t Tool) RequiresClientAuthorization() bool {
return t.UseClientOAuth
}

View File

@@ -0,0 +1,114 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package lookergenerateembedurl_test
import (
"strings"
"testing"
yaml "github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/genai-toolbox/internal/server"
"github.com/googleapis/genai-toolbox/internal/testutils"
lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergenerateembedurl"
)
func TestParseFromYamlLookerGenerateEmbedUrl(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
want server.ToolConfigs
}{
{
desc: "basic example",
in: `
tools:
example_tool:
kind: looker-generate-embed-url
source: my-instance
description: some description
`,
want: server.ToolConfigs{
"example_tool": lkr.Config{
Name: "example_tool",
Kind: "looker-generate-embed-url",
Source: "my-instance",
Description: "some description",
AuthRequired: []string{},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err != nil {
t.Fatalf("unable to unmarshal: %s", err)
}
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
t.Fatalf("incorrect parse: diff %v", diff)
}
})
}
}
func TestFailParseFromYamlLookerGenerateEmbedUrl(t *testing.T) {
ctx, err := testutils.ContextWithNewLogger()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
tcs := []struct {
desc string
in string
err string
}{
{
desc: "Invalid field",
in: `
tools:
example_tool:
kind: looker-generate-embed-url
source: my-instance
description: some description
invalid_field: "should not be here"
`,
err: "unknown field",
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
got := struct {
Tools server.ToolConfigs `yaml:"tools"`
}{}
// Parse contents
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
if err == nil {
t.Fatalf("expect parsing to fail")
}
errStr := err.Error()
if !strings.Contains(errStr, tc.err) {
t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err)
}
})
}
}

View File

@@ -199,6 +199,11 @@ func TestLooker(t *testing.T) {
"source": "my-instance",
"description": "Simple tool to test end to end functionality.",
},
"generate_embed_url": map[string]any{
"kind": "looker-generate-embed-url",
"source": "my-instance",
"description": "Simple tool to test end to end functionality.",
},
"get_connections": map[string]any{
"kind": "looker-get-connections",
"source": "my-instance",
@@ -1017,6 +1022,30 @@ func TestLooker(t *testing.T) {
},
},
)
tests.RunToolGetTestByName(t, "generate_embed_url",
map[string]any{
"generate_embed_url": map[string]any{
"description": "Simple tool to test end to end functionality.",
"authRequired": []any{},
"parameters": []any{
map[string]any{
"authSources": []any{},
"description": "Type of Looker content to embed (ie. dashboards, looks, query-visualization)",
"name": "type",
"required": false,
"type": "string",
},
map[string]any{
"authSources": []any{},
"description": "The ID of the content to embed.",
"name": "id",
"required": false,
"type": "string",
},
},
},
},
)
tests.RunToolGetTestByName(t, "get_connections",
map[string]any{
"get_connections": map[string]any{
@@ -1229,6 +1258,9 @@ func TestLooker(t *testing.T) {
wantResult = "{\"column_name\":\"EmpID\",\"data_type_database\":\"int\",\"data_type_looker\":\"number\",\"sql_escaped_column_name\":\"EmpID\"}"
tests.RunToolInvokeParametersTest(t, "get_connection_table_columns", []byte(`{"conn": "thelook", "schema": "demo_db", "tables": "Employees"}`), wantResult)
wantResult = "/login/embed?t=" // testing for specific substring, since url is dynamic
tests.RunToolInvokeParametersTest(t, "generate_embed_url", []byte(`{"type": "dashboards", "id": "1"}`), wantResult)
}
func runConversationalAnalytics(t *testing.T, modelName, exploreName string) {