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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-langsmith
3
+ Version: 0.1.0
4
+ Summary: Automatic LangSmith tracing for Dash MCP tool calls
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: dash>=3.0.0
7
+ Requires-Dist: langsmith>=0.1.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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-langsmith
3
+ Version: 0.1.0
4
+ Summary: Automatic LangSmith tracing for Dash MCP tool calls
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: dash>=3.0.0
7
+ Requires-Dist: langsmith>=0.1.0
@@ -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,2 @@
1
+ [dash_hooks]
2
+ dash_langsmith = dash_langsmith
@@ -0,0 +1,2 @@
1
+ dash>=3.0.0
2
+ langsmith>=0.1.0
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+