Skip to content
Merged
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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,19 @@ Go to the Looker API Explorer for Register OAuth App (https://your.looker.instan

## Code Mode

Execute Python code safely with full Looker SDK coverage within a secure sandbox environment. Constructed as an MCP tool, it dynamically inspects the Looker SDK for all public methods and injects them into the Monty sandbox safely. For detailed options, safe primitives transformations, and PKCE configurations, view the full [Code Mode Docs](./codemode.md).
Execute Python code safely with full Looker SDK coverage within a secure sandbox environment (`Monty`). It dynamically inspects the Looker SDK for all public methods and injects them into the sandbox safely, supporting both Model Context Protocol (MCP) server mode and direct standalone CLI execution.

### Direct CLI Execution (Sandbox Mode)
Run scripts or inline code directly against your active Looker instance:
```bash
# Execute inline Python code
uvx lkr-dev-cli code-mode sandbox --code="return me()"

# Execute a Python script file
uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py
```

For detailed options, safe primitive transformations, MCP server configurations, and PKCE recovery, view the full [Code Mode Docs](./codemode.md).

## MCP
Built into the `lkr` is an MCP server. Right now its tools are based on helping you work within an IDE. To use it a tool like [Cursor](https://www.cursor.com/), add this to your mcp.json
Expand Down
15 changes: 13 additions & 2 deletions codemode.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,19 @@ The tool instantiates Looker SDK natively, searches all bound methods, and passe

## Continuous Usage

### 1. Starting the Server
To immediately trigger the stdio listener, use:
### 1. Direct CLI Execution (Sandbox Mode)
If you want to execute Python scripts or inline code directly against your Looker instance without setting up an MCP client, use the `sandbox` command:

```bash
# Execute inline Python code
uvx lkr-dev-cli code-mode sandbox --code="return me()"

# Execute Python script file
uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py
```

### 2. Starting the Server
To immediately trigger the MCP stdio listener, use:
```bash
uvx -q lkr-dev-cli[codemode] code-mode run
```
Expand Down
62 changes: 60 additions & 2 deletions lkr/codemode/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ast
import inspect
import json
import os
import re
import sys
import tempfile
from contextlib import contextmanager
Expand All @@ -21,7 +23,7 @@
__all__ = ["group"]

mcp = FastMCP("lkr:codemode")
group = typer.Typer()
group = typer.Typer(no_args_is_help=True)
ctx_lkr: LkrCtxObj | None = None
class OSCapture:
def __init__(self):
Expand Down Expand Up @@ -156,6 +158,24 @@ class SDK:
setattr(SDK, name, staticmethod(func))
external_funcs['sdk'] = SDK

# Monty external_functions do not support attribute lookups on objects.
# Pre-process the code to replace `sdk.method_name` with `method_name` safely using AST.
try:
class SDKAttributeRewriter(ast.NodeTransformer):
def visit_Attribute(self, node):
self.generic_visit(node)
if isinstance(node.value, ast.Name) and node.value.id == 'sdk':
return ast.copy_location(ast.Name(id=node.attr, ctx=node.ctx), node)
return node

tree = ast.parse(code)
tree = SDKAttributeRewriter().visit(tree)
ast.fix_missing_locations(tree)
code = ast.unparse(tree)
except Exception:
# Fallback to regex if parsing fails
code = re.sub(r"\bsdk\.([a-zA-Z_][a-zA-Z0-9_]*)\b", r"\1", code)

m = pydantic_monty.Monty(code)

# Use low-level OS stdout capture to ensure any print() statements
Expand Down Expand Up @@ -191,7 +211,45 @@ class SDK:
return f"Error: {str(e)}"


@group.command(name="sandbox")
def sandbox(
ctx: typer.Context,
code: str | None = typer.Option(
None, "--code", "-c", help="Execute Python code directly in the sandbox"
),
file: str | None = typer.Option(
None, "--file", "-f", help="Execute Python code from a file in the sandbox"
),
dev_mode: bool = typer.Option(
False, "--dev-mode", help="Run in dev mode"
),
):
if not code and not file:
logger.error("Must specify either --code or --file")
raise typer.Exit(1)

if code and file:
logger.error("Cannot specify both --code and --file")
raise typer.Exit(1)

if file:
try:
with open(file, "r", encoding="utf-8") as f:
code_to_run = f.read()
except Exception as e:
logger.error(f"Failed to read file {file}: {e}")
raise typer.Exit(1)
Comment thread
bwebs marked this conversation as resolved.
else:
code_to_run = code

global ctx_lkr
ctx_lkr = (
ctx.obj.get("ctx_lkr")
if (ctx and ctx.obj and "ctx_lkr" in ctx.obj)
else LkrCtxObj(force_oauth=False)
)
Comment thread
bwebs marked this conversation as resolved.
result = run_python_code(code_to_run, dev_mode=dev_mode)
typer.echo(result)


@group.command(name="run")
Expand All @@ -202,7 +260,7 @@ def run(
global ctx_lkr
ctx_lkr = (
ctx.obj.get("ctx_lkr")
if (ctx.obj and "ctx_lkr" in ctx.obj)
if (ctx and ctx.obj and "ctx_lkr" in ctx.obj)
else LkrCtxObj(force_oauth=False)
)
mcp.run()
Expand Down
39 changes: 38 additions & 1 deletion tests/test_codemode.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from typing import Any, cast
import json
import os
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from typer.testing import CliRunner
from looker_sdk.rtl.auth_session import AuthSession
from looker_sdk.rtl.transport import Transport
from lkr.codemode.main import run_python_code
from lkr.extended_sdk_methods import ExtendedLooker40SDK
from lkr.main import app


class DummyAuth:
Expand Down Expand Up @@ -67,7 +71,7 @@ def test_sdk_object():
return me_obj["first_name"]
"""
result = run_python_code(code_sdk)
assert len(result) > 0
assert result == "Test"

def test_examples():
code_examples = """
Expand Down Expand Up @@ -283,3 +287,36 @@ def test_parent_runtime_not_polluted(capfd):

out, err = capfd.readouterr()
assert out == ""

runner = CliRunner()

def test_cli_code_mode_sandbox_code():
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()"])
assert result.exit_code == 0
assert "Test" in result.stdout

def test_cli_code_mode_sandbox_sdk_code():
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return sdk.me()"])
assert result.exit_code == 0
assert "Test" in result.stdout

def test_cli_code_mode_sandbox_file():
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("return me()")
temp_path = f.name

try:
result = runner.invoke(app, ["code-mode", "sandbox", "--file", temp_path])
assert result.exit_code == 0
assert "Test" in result.stdout
finally:
if os.path.exists(temp_path):
os.remove(temp_path)

def test_cli_code_mode_sandbox_both_error():
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()", "--file", "dummy.py"])
assert result.exit_code == 1

def test_cli_code_mode_sandbox_neither_error():
result = runner.invoke(app, ["code-mode", "sandbox"])
assert result.exit_code == 1
Loading