mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-11 08:28:11 -05:00
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:
@@ -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"
|
||||
|
||||
47
docs/en/resources/tools/looker/looker-generate-embed-url.md
Normal file
47
docs/en/resources/tools/looker/looker-generate-embed-url.md
Normal 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. |
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,6 +55,7 @@ func TestParseFromYamlLooker(t *testing.T) {
|
||||
ShowHiddenExplores: true,
|
||||
ShowHiddenFields: true,
|
||||
Location: "us",
|
||||
SessionLength: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user