Compare commits

..
Author SHA1 Message Date
syachamaneni-lc a2f96c3936 feat(cli): expose MCP task settings 2026-09-13 12:24:10 -07:00
syachamaneni-lc a9a4b83c4c release(sdk-py): 0.4.4 2026-08-27 17:05:21 -04:00
Sreekara YachamaneniandGitHub 5a77be5e8b Merge commit from fork
* authz fix for custom auth

* add back resource param

* auth on multiple resources
2026-08-27 13:28:59 -07:00
Mason DaughertyGitHubopen-swe[bot] <open-swe@users.noreply.github.com>
bdb8a9c7a4 feat: route LangSmith traces from thread streams (#8723)
## Description
Expose the existing `langsmith_tracing` option on Python sync and async
thread-stream run starts and forward it through the protocol.

## Release Note
Python thread streams can route traces to an additional LangSmith
project per run.

## Test Plan
- [x] Verify sync and async run-start payloads include tracing settings

## Related PRs
- langchain-ai/agent-protocol#95
- langchain-ai/langgraphjs#2745
- langchain-ai/langgraph-api#4033

Made by [Open
SWE](https://openswe.vercel.app/agents/f9e34294-b9c3-52f0-815a-0102188e1181)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-26 16:57:39 -04:00
John KennedyGitHublangsmith-fleet[bot] <langsmith-fleet[bot]@users.noreply.github.com>
38031739e5 docs: add Corridor agent instructions (#8677)
Add a repository-wide `<corridor>` block to `AGENTS.md` requiring agents
to plan first and run Corridor `analyzePlan` before generating or
modifying code. The tags are explicit and balanced so Corridor-specific
guidance remains scoped.

Validation: `git diff --check` and a tag-balance assertion.

---------

Co-authored-by: langsmith-fleet[bot] <langsmith-fleet[bot]@users.noreply.github.com>
2026-08-24 14:42:27 -07:00
21 changed files with 560 additions and 196 deletions
+3 -1
View File
@@ -32,6 +32,8 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
docker_compose=None,
port=port,
watch=False,
debugger_port=None,
debugger_base_url=f"http://127.0.0.1:{port}",
postgres_uri=None,
api_version=None,
image=tag,
@@ -171,5 +173,5 @@ if __name__ == "__main__":
except BaseException:
logger.exception("Test failed")
raise
logger.info("Test execution finished")
+2
View File
@@ -76,6 +76,8 @@ __pypackages__/
# Environments
.env
.env.*
*.crt
.envrc
*.crt
*.key
+8
View File
@@ -2,6 +2,14 @@
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
<corridor>
## Corridor security analysis
When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code.
</corridor>
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
- `make format` run code formatters
+8
View File
@@ -2,6 +2,14 @@
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
<corridor>
## Corridor security analysis
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
</corridor>
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
- `make format` run code formatters
+4
View File
@@ -23,6 +23,8 @@ from langgraph_cli.schemas import (
GraphDef,
HttpConfig,
IndexConfig,
McpConfig,
McpTasksConfig,
SecurityConfig,
SerdeConfig,
StoreConfig,
@@ -115,6 +117,8 @@ def add_descriptions_to_schema(schema, cls):
AuthConfig,
SecurityConfig,
HttpConfig,
McpConfig,
McpTasksConfig,
CorsConfig,
CacheConfig,
ThreadTTLConfig,
+3
View File
@@ -48,6 +48,9 @@ def get_anonymized_params(
if kwargs.get("docker_compose"):
params["docker_compose"] = True
if kwargs.get("debugger_port"):
params["debugger_port"] = True
if kwargs.get("postgres_uri"):
params["postgres_uri"] = True
+34 -89
View File
@@ -5,7 +5,6 @@ import pathlib
import shutil
import sys
from collections.abc import Sequence
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
import click
import click.exceptions
@@ -141,6 +140,17 @@ OPT_VERBOSE = click.option(
help="Show more output from the server logs",
)
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_DEBUGGER_BASE_URL = click.option(
"--debugger-base-url",
type=str,
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
)
OPT_POSTGRES_URI = click.option(
"--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database",
@@ -232,94 +242,18 @@ cli.add_command(deploy)
# ---------------------------------------------------------------------------
def _validated_http_url(value: str, option_name: str) -> SplitResult:
try:
parsed = urlsplit(value)
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
) from exc
if (
value != value.strip()
or parsed.scheme not in {"http", "https"}
or not parsed.netloc
or not hostname
or parsed.username is not None
or parsed.password is not None
):
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
)
return parsed
def _studio_link(
*,
port: int,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
) -> str:
if debugger_base_url is not None:
if api_url is not None and api_url != debugger_base_url:
raise click.UsageError(
"--api-url and --debugger-base-url cannot specify different URLs."
)
click.echo(
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
err=True,
)
api_url = debugger_base_url
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
studio_parts = _validated_http_url(studio_url, "--studio-url")
_validated_http_url(api_url, "--api-url")
if studio_parts.query or studio_parts.fragment:
raise click.UsageError(
"--studio-url must not include a query string or fragment."
)
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
return urlunsplit(
studio_parts._replace(
path=studio_path,
query=urlencode({"baseUrl": api_url}),
)
)
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
"--studio-url",
type=str,
default=None,
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
)
@click.option(
"--api-url",
type=str,
default=None,
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
)
@click.option(
"--debugger-base-url",
type=str,
default=None,
hidden=True,
)
@click.option(
"--image",
type=str,
@@ -350,21 +284,14 @@ def up(
watch: bool,
wait: bool,
verbose: bool,
debugger_port: int | None,
debugger_base_url: str | None,
postgres_uri: str | None,
api_version: str | None,
engine_runtime_mode: str,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
image: str | None,
base_image: str | None,
):
studio_link = _studio_link(
port=port,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=debugger_base_url,
)
click.secho("Starting LangGraph API server...", fg="green")
click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
@@ -381,6 +308,8 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
pull=pull,
watch=watch,
verbose=verbose,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
@@ -408,12 +337,20 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
if "unpacking to docker.io" in line:
set("Starting...")
elif "Application startup complete" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://smith.langchain.com"
)
debugger_base_url_query = (
debugger_base_url or f"http://127.0.0.1:{port}"
)
set("")
sys.stdout.write(
f"""Ready!
- API: http://localhost:{port}
- Docs: http://localhost:{port}/docs
- LangGraph Studio: {studio_link}
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
"""
)
sys.stdout.flush()
@@ -998,6 +935,8 @@ def prepare_args_and_stdin(
docker_compose: pathlib.Path | None,
port: int,
watch: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
@@ -1011,6 +950,8 @@ def prepare_args_and_stdin(
stdin = langgraph_cli.docker.compose(
capabilities,
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
base_image=base_image,
@@ -1048,6 +989,8 @@ def prepare(
pull: bool,
watch: bool,
verbose: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None,
api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker",
@@ -1089,6 +1032,8 @@ def prepare(
docker_compose=docker_compose,
port=port,
watch=watch,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri,
api_version=api_version,
engine_runtime_mode=engine_runtime_mode,
+36 -1
View File
@@ -142,6 +142,29 @@ def check_capabilities(runner) -> DockerCapabilities:
)
def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
if port is None:
return ""
config = {
"langgraph-debugger": {
"image": "langchain/langgraph-debugger",
"restart": "on-failure",
"depends_on": {
"langgraph-postgres": {"condition": "service_healthy"},
},
"ports": [f'"{port}:3968"'],
}
}
if base_url:
config["langgraph-debugger"]["environment"] = {
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
}
return config
# Function to convert dictionary to YAML
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
"""Convert a dictionary to a YAML string."""
@@ -168,6 +191,8 @@ def compose_as_dict(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None,
# If you are running against an already-built image, you can pass it here
@@ -228,6 +253,12 @@ def compose_as_dict(
else:
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
# Add optional debugger service if debugger_port is specified
if debugger_port:
services["langgraph-debugger"] = debugger_compose(
port=debugger_port, base_url=debugger_base_url
)["langgraph-debugger"]
# Add langgraph-api service
api_environment = {
"REDIS_URI": "redis://langgraph-redis:6379",
@@ -258,7 +289,7 @@ def compose_as_dict(
"test": "python /api/healthcheck.py",
"interval": "60s",
"start_interval": "1s",
"start_period": "60s",
"start_period": "10s",
}
# Final compose dictionary with volumes included if needed
@@ -274,6 +305,8 @@ def compose(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None,
image: str | None = None,
@@ -285,6 +318,8 @@ def compose(
compose_content = compose_as_dict(
capabilities,
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
base_image=base_image,
+20
View File
@@ -439,6 +439,24 @@ class ConfigurableHeaderConfig(TypedDict, total=False):
"""
class McpTasksConfig(TypedDict, total=False):
"""Settings for authenticated MCP elicitation tasks."""
poll_interval_ms: int
"""Suggested client polling interval, in milliseconds. Default 1000; range 10060000."""
ttl_minutes: int
"""Maximum task lifetime, in minutes. Default 1440; range 110080."""
completed_grace_seconds: int
"""Result retention after completion, in seconds. Default 300; range 186400."""
allow_insecure_urls: bool
"""Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."""
class McpConfig(TypedDict, total=False):
tasks: McpTasksConfig
"""Polling, retention and URL settings for the MCP Tasks extension."""
class HttpConfig(TypedDict, total=False):
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
@@ -473,6 +491,8 @@ class HttpConfig(TypedDict, total=False):
Default is False.
"""
mcp: McpConfig
"""Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."""
disable_a2a: bool
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
+40
View File
@@ -1065,6 +1065,10 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1170,6 +1174,42 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
+40
View File
@@ -1065,6 +1065,10 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1170,6 +1174,42 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
+31 -80
View File
@@ -8,11 +8,10 @@ from contextlib import contextmanager
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines
@@ -57,6 +56,8 @@ def test_prepare_args_and_stdin() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -64,6 +65,8 @@ def test_prepare_args_and_stdin() -> None:
config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True,
)
@@ -107,6 +110,16 @@ services:
retries: 5
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api:
ports:
- "8000:8000"
@@ -122,7 +135,7 @@ services:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 60s
start_period: 10s
pull_policy: build
build:
@@ -165,6 +178,8 @@ def test_prepare_args_and_stdin_with_image() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -172,6 +187,8 @@ def test_prepare_args_and_stdin_with_image() -> None:
config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True,
image="my-cool-image",
)
@@ -216,6 +233,16 @@ services:
retries: 5
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api:
ports:
- "8000:8000"
@@ -232,7 +259,7 @@ services:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 60s
start_period: 10s
develop:
@@ -262,82 +289,6 @@ def test_version_option() -> None:
)
def test_up_help_shows_hosted_studio_options() -> None:
result = CliRunner().invoke(cli, ["up", "--help"])
assert result.exit_code == 0, result.output
assert "--studio-url" in result.output
assert "--api-url" in result.output
assert "--debugger-port" not in result.output
assert "--debugger-base-url" not in result.output
def test_studio_link_defaults_to_hosted_studio() -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url=None,
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
assert _studio_link(
port=8123,
studio_url="https://langsmith.example.com/prefix/",
api_url="https://api.example.com/graph?tenant=a&region=eu",
debugger_base_url=None,
) == (
"https://langsmith.example.com/prefix/studio/"
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
)
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url="https://api.example.com",
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
@pytest.mark.parametrize(
("studio_url", "api_url"),
[
("javascript:alert(1)", None),
("https://user:password@example.com", None),
("https://smith.langchain.com?workspace=test", None),
(None, "file:///tmp/langgraph.sock"),
(None, "https://user:password@example.com"),
],
)
def test_studio_link_rejects_unsafe_urls(
studio_url: str | None, api_url: str | None
) -> None:
with pytest.raises(click.UsageError):
_studio_link(
port=8123,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=None,
)
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
with pytest.raises(
click.UsageError,
match="cannot specify different URLs",
):
_studio_link(
port=8123,
studio_url=None,
api_url="https://api.example.com",
debugger_base_url="https://other.example.com",
)
def test_top_level_help_shows_deploy_subcommands() -> None:
runner = CliRunner()
+98 -4
View File
@@ -16,7 +16,7 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
)
def test_compose_with_custom_db():
def test_compose_with_no_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
@@ -42,7 +42,7 @@ def test_compose_with_custom_db():
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_custom_db_and_healthcheck():
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
@@ -71,11 +71,39 @@ def test_compose_with_custom_db_and_healthcheck():
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 60s"""
start_period: 10s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_default_db():
def test_compose_with_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_default_db():
port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes:
@@ -274,6 +302,72 @@ def test_compose_with_api_version_and_custom_postgres():
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_debugger():
"""Test compose function with api_version and debugger port."""
port = 8123
debugger_port = 8001
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
debugger_port=debugger_port,
)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_custom_db():
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
port = 8123
+9
View File
@@ -39,6 +39,15 @@
- `client.threads.stream()` now accepts `transport="sse"` (default) or
`transport="websocket"` in place of the previous transport-agnostic default.
### Fixed
- Resource-scoped auth decorators now honor `actions=` and reject empty or
invalid action lists. Because unmatched custom-auth paths remain allowed,
deployments using action-scoped handlers should configure a global
default-deny handler; `langgraph-api` 0.10+ warns about uncovered paths at
startup. Resource-specific decorators retain matching `resources=` selectors
for backward compatibility; use `@auth.on(resources=...)` for other resources.
### Notes
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
+1 -1
View File
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
__version__ = "0.4.3"
__version__ = "0.4.4"
__all__ = [
"Auth",
+4 -1
View File
@@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
DataDecoder,
@@ -172,6 +172,7 @@ class RunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -181,6 +182,8 @@ class RunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
loop = asyncio.get_running_loop()
gate: asyncio.Future[None] = loop.create_future()
self._owner._run_start_ready = gate
+4 -1
View File
@@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.decoders import (
DataDecoder,
Decoder,
@@ -215,6 +215,7 @@ class SyncRunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -224,6 +225,8 @@ class SyncRunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
result = self._owner._send_command("run.start", params)
self._owner._run_seen = True
controller = self._owner._controller
+67 -16
View File
@@ -341,9 +341,15 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
VRead = typing.TypeVar("VRead", covariant=True)
VDelete = typing.TypeVar("VDelete", covariant=True)
VSearch = typing.TypeVar("VSearch", covariant=True)
ResourceActionT = typing.TypeVar("ResourceActionT", bound=str)
_ResourceAction = typing.Literal["create", "read", "update", "delete", "search"]
_ThreadAction = _ResourceAction | typing.Literal["create_run"]
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
class _ResourceOn(
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
):
"""
Generic base class for resource-specific handlers.
"""
@@ -392,8 +398,8 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
def __call__(
self,
*,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
resources: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> Callable[
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
@@ -408,7 +414,7 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
) = None,
*,
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| Callable[
@@ -416,24 +422,66 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
]
):
if fn is not None:
_validate_handler(fn)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", fn),
)
def decorator(
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
_validate_handler(handler)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", handler),
if resources is None:
resource_list = [self.resource]
elif isinstance(resources, str):
resource_list = [resources]
elif isinstance(resources, Sequence):
resource_list = list(resources)
else:
raise TypeError("resources must be a string or sequence of strings")
if resource_list != [self.resource]:
raise ValueError(
f"Resource-specific decorator for {self.resource!r} cannot "
f"register handlers for {resource_list!r}. Use @auth.on(...) "
"for other or multiple resources."
)
if actions is None:
action_list = ["*"]
elif isinstance(actions, str):
action_list = [actions]
elif isinstance(actions, Sequence):
action_list = list(actions)
else:
raise TypeError("actions must be a string or sequence of strings")
if not action_list:
raise ValueError("actions must not be empty")
if not all(isinstance(action, str) for action in action_list):
raise TypeError("actions must be a string or sequence of strings")
valid_actions = {
value.action
for value in vars(self).values()
if isinstance(value, _ResourceActionOn)
}
invalid_actions = (
sorted(set(action_list) - valid_actions) if actions is not None else []
)
if invalid_actions:
raise ValueError(
f"Invalid action(s) for {self.resource}: {', '.join(invalid_actions)}"
)
if len(action_list) != len(set(action_list)):
raise ValueError("actions must not contain duplicates")
for action in action_list:
if (self.resource, action) in self.auth._handlers:
raise ValueError(
f"types.Handler already set for {self.resource}, {action}."
)
for action in action_list:
_register_handler(self.auth, self.resource, action, handler)
return handler
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
_ = resources, actions
if fn is not None:
return decorator(
typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
fn,
)
)
return decorator
@@ -444,6 +492,7 @@ class _AssistantsOn(
types.AssistantsUpdate,
types.AssistantsDelete,
types.AssistantsSearch,
_ResourceAction,
]
):
value = (
@@ -467,6 +516,7 @@ class _ThreadsOn(
types.ThreadsUpdate,
types.ThreadsDelete,
types.ThreadsSearch,
_ThreadAction,
]
):
value = (
@@ -502,6 +552,7 @@ class _CronsOn(
types.CronsUpdate,
types.CronsDelete,
types.CronsSearch,
_ResourceAction,
]
):
value = type[
@@ -426,11 +426,17 @@ def test_sync_run_start_sends_command():
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
result = thread.run.start(input={"x": 1})
result = thread.run.start(
input={"x": 1},
langsmith_tracing={"project_name": "replica-project"},
)
assert result == {"run_id": "run-1"}
assert fake.received_commands[0]["method"] == "run.start"
assert fake.received_commands[0]["params"]["assistant_id"] == "agent"
assert fake.received_commands[0]["params"]["langsmith_tracer"] == {
"project_name": "replica-project"
}
def test_sync_events_iterates_raw_events():
@@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic():
assert [c["id"] for c in fake.received_commands] == [1, 2]
async def test_run_start_forwards_config_and_metadata():
async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
@@ -297,10 +297,18 @@ async def test_run_start_forwards_config_and_metadata():
input={"x": 1},
config={"recursion_limit": 5},
metadata={"trace": "abc"},
langsmith_tracing={
"project_name": "replica-project",
"example_id": "example-1",
},
)
params = fake.received_commands[0]["params"]
assert params["config"] == {"recursion_limit": 5}
assert params["metadata"] == {"trace": "abc"}
assert params["langsmith_tracer"] == {
"project_name": "replica-project",
"example_id": "example-1",
}
async def test_run_start_raises_outside_context_manager():
+132
View File
@@ -0,0 +1,132 @@
import pytest
from langgraph_sdk import Auth
def test_handler_multiple_resources_and_actions() -> None:
auth = Auth()
@auth.on(resources=["threads", "assistants"], actions=["read", "search"])
async def allow_reads(ctx, value):
del value
return {"owner": ctx.user.identity}
assert auth._handlers == {
("threads", "read"): [allow_reads],
("threads", "search"): [allow_reads],
("assistants", "read"): [allow_reads],
("assistants", "search"): [allow_reads],
}
def test_resource_handler_actions_are_scoped() -> None:
auth = Auth()
@auth.on
async def deny_all(ctx, value):
del ctx, value
return False
@auth.on.threads(actions=["create", "search"])
async def handler(ctx, value):
del ctx, value
return None
@auth.on.threads(actions="create_run")
async def run_handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {
("threads", "create"): [handler],
("threads", "search"): [handler],
("threads", "create_run"): [run_handler],
}
assert auth._global_handlers == [deny_all]
def test_resource_handler_preserves_wildcard() -> None:
auth = Auth()
@auth.on.threads
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "*"): [handler]}
def test_resource_handler_preserves_wildcard_with_parentheses() -> None:
auth = Auth()
@auth.on.threads()
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "*"): [handler]}
def test_resource_handler_accepts_matching_resource() -> None:
auth = Auth()
@auth.on.threads(resources=["threads"], actions="read")
async def handler(ctx, value):
del ctx, value
return None
assert auth._handlers == {("threads", "read"): [handler]}
@pytest.mark.parametrize(
"resources", [["assistants"], ["threads", "assistants"], [], [1]]
)
def test_resource_handler_rejects_nonmatching_resources(resources) -> None:
auth = Auth()
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(ValueError, match=r"Use @auth\.on"):
auth.on.threads(resources=resources)(handler)
assert auth._handlers == {}
@pytest.mark.parametrize(
("resource", "actions", "error"),
[
("threads", [], ValueError),
("threads", ["reed"], ValueError),
("threads", ["create", "create"], ValueError),
("threads", {"create": True}, TypeError),
("crons", ["create_run"], ValueError),
],
)
def test_resource_handler_rejects_invalid_actions(resource, actions, error) -> None:
auth = Auth()
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(error):
getattr(auth.on, resource)(actions=actions)(handler)
assert auth._handlers == {}
def test_resource_handler_registration_is_atomic() -> None:
auth = Auth()
@auth.on.threads.read
async def read_handler(ctx, value):
del ctx, value
return None
async def handler(ctx, value):
del ctx, value
return None
with pytest.raises(ValueError, match="already set"):
auth.on.threads(actions=["create", "read"])(handler)
assert auth._handlers == {("threads", "read"): [read_handler]}