dash-langsmith 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dash_langsmith-0.1.0/PKG-INFO +7 -0
- dash_langsmith-0.1.0/README.md +55 -0
- dash_langsmith-0.1.0/dash_langsmith/__init__.py +39 -0
- dash_langsmith-0.1.0/dash_langsmith/decorator.py +62 -0
- dash_langsmith-0.1.0/dash_langsmith/middleware.py +190 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/PKG-INFO +7 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/SOURCES.txt +11 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/dependency_links.txt +1 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/entry_points.txt +2 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/requires.txt +2 -0
- dash_langsmith-0.1.0/dash_langsmith.egg-info/top_level.txt +1 -0
- dash_langsmith-0.1.0/pyproject.toml +19 -0
- dash_langsmith-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# dash-langsmith
|
|
2
|
+
|
|
3
|
+
Automatic [LangSmith](https://smith.langchain.com) tracing for [Dash MCP](https://dash.plotly.com/dash-mcp) tool calls.
|
|
4
|
+
|
|
5
|
+
Every time an AI agent calls a tool on your Dash MCP server, a run is recorded in LangSmith — latency, inputs, outputs, and errors included. No code changes to your app required.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install dash-langsmith
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Set your API key and run your app normally.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export LANGSMITH_API_KEY=your-key
|
|
19
|
+
export LANGSMITH_PROJECT=my-dash-app # optional
|
|
20
|
+
python app.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's it. The `dash_hooks` entry point registers the tracing middleware automatically at startup.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
`dash-langsmith` uses Dash's [plugin hooks](https://dash.plotly.com/dash-plugins-using-hooks) system. On startup, `hooks.setup` wraps `app.server.wsgi_app` with a lightweight WSGI middleware that intercepts POST requests to `/_mcp`. When a `tools/call` JSON-RPC message arrives, it creates a LangSmith run before dispatching to Dash and patches it with the response on the way out.
|
|
28
|
+
|
|
29
|
+
Only `tools/call` requests are traced. Layout, resource, and `tools/list` requests pass through untouched.
|
|
30
|
+
|
|
31
|
+
## Optional: richer traces with `@mcp_traced`
|
|
32
|
+
|
|
33
|
+
For explicit per-tool control — custom names, docstrings, per-tool project routing — use the `@mcp_traced` decorator instead of `@mcp_enabled`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from dash_langsmith import mcp_traced
|
|
37
|
+
|
|
38
|
+
@mcp_traced(name="get_inventory", expose_docstring=True, project_name="my-dash-app")
|
|
39
|
+
def get_inventory(category: str) -> dict:
|
|
40
|
+
"""Return inventory levels for a product category."""
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
| Environment variable | Default | Description |
|
|
47
|
+
|-----------------------|------------|--------------------------------------|
|
|
48
|
+
| `LANGSMITH_API_KEY` | — | Required |
|
|
49
|
+
| `LANGSMITH_PROJECT` | — | LangSmith project (falls back to `LANGCHAIN_PROJECT`) |
|
|
50
|
+
| `DASH_MCP_PATH` | `/_mcp` | Override if you set a custom `mcp_path` |
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Dash ≥ 4.3.0 (MCP support)
|
|
55
|
+
- langsmith ≥ 0.1.0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dash-langsmith: automatic LangSmith tracing for Dash MCP tool calls.
|
|
3
|
+
|
|
4
|
+
Install this package and every MCP tools/call request to your Dash app is
|
|
5
|
+
recorded as a LangSmith run — no code changes required.
|
|
6
|
+
|
|
7
|
+
Configuration via environment variables:
|
|
8
|
+
LANGSMITH_API_KEY (required)
|
|
9
|
+
LANGSMITH_PROJECT LangSmith project name (optional, falls back to LANGCHAIN_PROJECT)
|
|
10
|
+
DASH_MCP_PATH MCP endpoint path (default: /_mcp)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from dash import hooks
|
|
16
|
+
|
|
17
|
+
from .decorator import mcp_traced
|
|
18
|
+
from .middleware import LangSmithMCPMiddleware
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@hooks.setup()
|
|
22
|
+
def _install_mcp_tracing(app):
|
|
23
|
+
mcp_path = (
|
|
24
|
+
getattr(app, "mcp_path", None)
|
|
25
|
+
or os.environ.get("DASH_MCP_PATH", "/_mcp")
|
|
26
|
+
)
|
|
27
|
+
project_name = (
|
|
28
|
+
os.environ.get("LANGSMITH_PROJECT")
|
|
29
|
+
or os.environ.get("LANGCHAIN_PROJECT")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.server.wsgi_app = LangSmithMCPMiddleware(
|
|
33
|
+
app.server.wsgi_app,
|
|
34
|
+
mcp_path=mcp_path,
|
|
35
|
+
project_name=project_name,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ["LangSmithMCPMiddleware", "mcp_traced"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@mcp_traced decorator: wraps @mcp_enabled and adds LangSmith tracing.
|
|
3
|
+
|
|
4
|
+
Use this instead of @mcp_enabled when you want per-tool traces with
|
|
5
|
+
structured inputs and outputs recorded in LangSmith.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import langsmith
|
|
11
|
+
from dash.mcp import mcp_enabled as _dash_mcp_enabled
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mcp_traced(
|
|
15
|
+
func=None,
|
|
16
|
+
*,
|
|
17
|
+
name: Optional[str] = None,
|
|
18
|
+
expose_docstring: Optional[bool] = None,
|
|
19
|
+
project_name: Optional[str] = None,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Expose a function as a Dash MCP tool and trace every invocation to LangSmith.
|
|
23
|
+
|
|
24
|
+
Supports both bare and parameterized usage::
|
|
25
|
+
|
|
26
|
+
@mcp_traced
|
|
27
|
+
def get_sales(region: str) -> dict:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@mcp_traced(name="sales_by_region", expose_docstring=True, project_name="my-app")
|
|
31
|
+
def get_sales(region: str) -> dict:
|
|
32
|
+
\"\"\"Return sales total for a given region.\"\"\"
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: Tool name shown to AI agents. Defaults to the function name.
|
|
37
|
+
expose_docstring: Whether to include the docstring in the tool description.
|
|
38
|
+
Inherits the app-wide setting when unset.
|
|
39
|
+
project_name: LangSmith project to record runs under.
|
|
40
|
+
Falls back to the LANGCHAIN_PROJECT env var when unset.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def decorator(fn):
|
|
44
|
+
traced = langsmith.traceable(
|
|
45
|
+
name=name or fn.__name__,
|
|
46
|
+
run_type="tool",
|
|
47
|
+
project_name=project_name,
|
|
48
|
+
)(fn)
|
|
49
|
+
|
|
50
|
+
mcp_kwargs = {}
|
|
51
|
+
if name is not None:
|
|
52
|
+
mcp_kwargs["name"] = name
|
|
53
|
+
if expose_docstring is not None:
|
|
54
|
+
mcp_kwargs["expose_docstring"] = expose_docstring
|
|
55
|
+
|
|
56
|
+
if mcp_kwargs:
|
|
57
|
+
return _dash_mcp_enabled(**mcp_kwargs)(traced)
|
|
58
|
+
return _dash_mcp_enabled(traced)
|
|
59
|
+
|
|
60
|
+
if func is not None:
|
|
61
|
+
return decorator(func)
|
|
62
|
+
return decorator
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSGI middleware that traces Dash MCP tool calls to LangSmith.
|
|
3
|
+
|
|
4
|
+
Drop-in: wrap app.server.wsgi_app and every tools/call request to your
|
|
5
|
+
Dash MCP endpoint is automatically recorded as a LangSmith run.
|
|
6
|
+
|
|
7
|
+
Parent-run linking
|
|
8
|
+
------------------
|
|
9
|
+
If the MCP client injects a LangSmith run ID into the tool call's _meta
|
|
10
|
+
field, the Dash run is created as a child of that run — giving you a full
|
|
11
|
+
trace from LLM decision → Dash tool → result in one LangSmith tree.
|
|
12
|
+
|
|
13
|
+
On the agent side (e.g. langchain-mcp-adapters), emit:
|
|
14
|
+
|
|
15
|
+
{"method": "tools/call", "params": {
|
|
16
|
+
"name": "...", "arguments": {...},
|
|
17
|
+
"_meta": {"langsmith_run_id": "<parent-run-uuid>"}
|
|
18
|
+
}}
|
|
19
|
+
|
|
20
|
+
Session tagging
|
|
21
|
+
---------------
|
|
22
|
+
The MCP initialize handshake is intercepted to extract the client name
|
|
23
|
+
and version. All subsequent tool-call runs from that session are tagged
|
|
24
|
+
with the client info.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import io
|
|
28
|
+
import json
|
|
29
|
+
import uuid
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
import langsmith
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LangSmithMCPMiddleware:
|
|
37
|
+
"""
|
|
38
|
+
WSGI middleware that intercepts Dash MCP tool calls and traces them to LangSmith.
|
|
39
|
+
|
|
40
|
+
Automatically installed via the dash_hooks entry point when dash-langsmith
|
|
41
|
+
is installed. Can also be applied manually::
|
|
42
|
+
|
|
43
|
+
app.server.wsgi_app = LangSmithMCPMiddleware(
|
|
44
|
+
app.server.wsgi_app,
|
|
45
|
+
project_name="my-dash-app",
|
|
46
|
+
)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
app,
|
|
52
|
+
mcp_path: str = "/_mcp",
|
|
53
|
+
project_name: Optional[str] = None,
|
|
54
|
+
):
|
|
55
|
+
self.app = app
|
|
56
|
+
self.mcp_path = mcp_path
|
|
57
|
+
self.project_name = project_name
|
|
58
|
+
self.client = langsmith.Client()
|
|
59
|
+
# session_id -> {"client_name": str, "client_version": str}
|
|
60
|
+
self._sessions: dict[str, dict] = {}
|
|
61
|
+
|
|
62
|
+
def __call__(self, environ, start_response):
|
|
63
|
+
path = environ.get("PATH_INFO", "")
|
|
64
|
+
method = environ.get("REQUEST_METHOD", "")
|
|
65
|
+
|
|
66
|
+
if not (path.endswith(self.mcp_path) and method == "POST"):
|
|
67
|
+
return self.app(environ, start_response)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
content_length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
content_length = 0
|
|
73
|
+
|
|
74
|
+
body = environ["wsgi.input"].read(content_length)
|
|
75
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
rpc = json.loads(body)
|
|
79
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
80
|
+
return self.app(environ, start_response)
|
|
81
|
+
|
|
82
|
+
rpc_method = rpc.get("method")
|
|
83
|
+
|
|
84
|
+
# Intercept initialize to capture client identity for session tagging.
|
|
85
|
+
if rpc_method == "initialize":
|
|
86
|
+
self._handle_initialize(environ, rpc)
|
|
87
|
+
return self.app(environ, start_response)
|
|
88
|
+
|
|
89
|
+
if rpc_method != "tools/call":
|
|
90
|
+
return self.app(environ, start_response)
|
|
91
|
+
|
|
92
|
+
return self._trace_tool_call(environ, start_response, rpc)
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _handle_initialize(self, environ, rpc: dict) -> None:
|
|
97
|
+
session_id = self._session_id(environ)
|
|
98
|
+
client_info = rpc.get("params", {}).get("clientInfo", {})
|
|
99
|
+
self._sessions[session_id] = {
|
|
100
|
+
"client_name": client_info.get("name", "unknown"),
|
|
101
|
+
"client_version": client_info.get("version", ""),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def _trace_tool_call(self, environ, start_response, rpc: dict):
|
|
105
|
+
params = rpc.get("params", {})
|
|
106
|
+
tool_name = params.get("name", "unknown_tool")
|
|
107
|
+
arguments = params.get("arguments", {})
|
|
108
|
+
meta = params.get("_meta", {})
|
|
109
|
+
|
|
110
|
+
# Link to a parent LangSmith run if the agent provided one.
|
|
111
|
+
parent_run_id: Optional[str] = meta.get("langsmith_run_id")
|
|
112
|
+
|
|
113
|
+
# Tag with the MCP client identity captured at initialize.
|
|
114
|
+
session_id = self._session_id(environ)
|
|
115
|
+
session = self._sessions.get(session_id, {})
|
|
116
|
+
tags = ["dash-mcp"]
|
|
117
|
+
if session.get("client_name"):
|
|
118
|
+
tags.append(f"mcp-client:{session['client_name']}")
|
|
119
|
+
|
|
120
|
+
extra = {"metadata": {"mcp_session_id": session_id, **session}}
|
|
121
|
+
if meta:
|
|
122
|
+
extra["metadata"]["mcp_meta"] = meta
|
|
123
|
+
|
|
124
|
+
run_id = str(uuid.uuid4())
|
|
125
|
+
start_time = datetime.now(timezone.utc)
|
|
126
|
+
|
|
127
|
+
self.client.create_run(
|
|
128
|
+
id=run_id,
|
|
129
|
+
name=tool_name,
|
|
130
|
+
run_type="tool",
|
|
131
|
+
inputs={"arguments": arguments},
|
|
132
|
+
project_name=self.project_name,
|
|
133
|
+
parent_run_id=parent_run_id,
|
|
134
|
+
start_time=start_time,
|
|
135
|
+
tags=tags,
|
|
136
|
+
extra=extra,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
response_chunks = []
|
|
140
|
+
response_meta: dict = {}
|
|
141
|
+
|
|
142
|
+
def capturing_start_response(status, headers, exc_info=None):
|
|
143
|
+
response_meta["status"] = status
|
|
144
|
+
return start_response(status, headers, exc_info)
|
|
145
|
+
|
|
146
|
+
iterable = self.app(environ, capturing_start_response)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
for chunk in iterable:
|
|
150
|
+
response_chunks.append(chunk)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
self.client.update_run(
|
|
153
|
+
run_id,
|
|
154
|
+
error=str(exc),
|
|
155
|
+
end_time=datetime.now(timezone.utc),
|
|
156
|
+
)
|
|
157
|
+
raise
|
|
158
|
+
finally:
|
|
159
|
+
if hasattr(iterable, "close"):
|
|
160
|
+
iterable.close()
|
|
161
|
+
|
|
162
|
+
full_body = b"".join(response_chunks)
|
|
163
|
+
end_time = datetime.now(timezone.utc)
|
|
164
|
+
|
|
165
|
+
outputs: dict = {}
|
|
166
|
+
error: Optional[str] = None
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
resp_json = json.loads(full_body)
|
|
170
|
+
if "result" in resp_json:
|
|
171
|
+
outputs = {"result": resp_json["result"]}
|
|
172
|
+
elif "error" in resp_json:
|
|
173
|
+
error = json.dumps(resp_json["error"])
|
|
174
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
175
|
+
outputs = {"raw": full_body.decode("utf-8", errors="replace")}
|
|
176
|
+
|
|
177
|
+
self.client.update_run(
|
|
178
|
+
run_id,
|
|
179
|
+
outputs=outputs,
|
|
180
|
+
error=error,
|
|
181
|
+
end_time=end_time,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return [full_body]
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _session_id(environ: dict) -> str:
|
|
188
|
+
# MCP HTTP transport uses mcp-session-id header per the spec.
|
|
189
|
+
key = "HTTP_MCP_SESSION_ID"
|
|
190
|
+
return environ.get(key) or "no-session"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
dash_langsmith/__init__.py
|
|
4
|
+
dash_langsmith/decorator.py
|
|
5
|
+
dash_langsmith/middleware.py
|
|
6
|
+
dash_langsmith.egg-info/PKG-INFO
|
|
7
|
+
dash_langsmith.egg-info/SOURCES.txt
|
|
8
|
+
dash_langsmith.egg-info/dependency_links.txt
|
|
9
|
+
dash_langsmith.egg-info/entry_points.txt
|
|
10
|
+
dash_langsmith.egg-info/requires.txt
|
|
11
|
+
dash_langsmith.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dash_langsmith
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dash-langsmith"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Automatic LangSmith tracing for Dash MCP tool calls"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"dash>=3.0.0",
|
|
12
|
+
"langsmith>=0.1.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.entry-points."dash_hooks"]
|
|
16
|
+
dash_langsmith = "dash_langsmith"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["dash_langsmith"]
|