dirigent-mcp 0.27.1__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,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-mcp
3
+ Version: 0.27.1
4
+ Summary: An MCP server that lets an agent operate a dirigent instance through its API.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-client==0.27.1
10
+ Requires-Dist: dirigent-common==0.27.1
11
+ Requires-Dist: mcp>=2.3.0
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dirigent-mcp
17
+
18
+ An MCP server that lets an agent operate a dirigent instance through its API: write and apply
19
+ pipeline documents, start runs and read them, and pause or resume sources and schedules. Its
20
+ tools are calls the API already answers, so the agent may do what its token's role may do, and
21
+ none of them deletes anything. An agent that may apply and run a document runs it with every
22
+ connection the instance holds: give a `viewer` token and set `DG_MCP_READ_ONLY=1` when it should
23
+ only read.
24
+
25
+ ```bash
26
+ claude mcp add dirigent -e DG_URL=https://dirigent.example.org -e DG_TOKEN=... -- uvx dirigent-mcp
27
+ ```
28
+
29
+ Any MCP client starts it the same way: `uvx dirigent-mcp` over stdio, with `DG_URL` and
30
+ `DG_TOKEN` in its environment. See [Working with a coding agent](https://winterop-com.github.io/dirigent/agents/)
31
+ for the tools it offers.
@@ -0,0 +1,16 @@
1
+ # dirigent-mcp
2
+
3
+ An MCP server that lets an agent operate a dirigent instance through its API: write and apply
4
+ pipeline documents, start runs and read them, and pause or resume sources and schedules. Its
5
+ tools are calls the API already answers, so the agent may do what its token's role may do, and
6
+ none of them deletes anything. An agent that may apply and run a document runs it with every
7
+ connection the instance holds: give a `viewer` token and set `DG_MCP_READ_ONLY=1` when it should
8
+ only read.
9
+
10
+ ```bash
11
+ claude mcp add dirigent -e DG_URL=https://dirigent.example.org -e DG_TOKEN=... -- uvx dirigent-mcp
12
+ ```
13
+
14
+ Any MCP client starts it the same way: `uvx dirigent-mcp` over stdio, with `DG_URL` and
15
+ `DG_TOKEN` in its environment. See [Working with a coding agent](https://winterop-com.github.io/dirigent/agents/)
16
+ for the tools it offers.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "dirigent-mcp"
3
+ version = "0.27.1"
4
+ description = "An MCP server that lets an agent operate a dirigent instance through its API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-client==0.27.1",
15
+ "dirigent-common==0.27.1",
16
+ "mcp>=2.3.0",
17
+ "pyyaml>=6.0.3",
18
+ ]
19
+
20
+ [project.scripts]
21
+ dirigent-mcp = "dirigent_mcp.server:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.0,<0.13.0"]
25
+ build-backend = "uv_build"
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "dirigent-mcp"
3
+ version = "0.27.1"
4
+ description = "An MCP server that lets an agent operate a dirigent instance through its API."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-client==0.27.1",
15
+ "dirigent-common==0.27.1",
16
+ "mcp>=2.3.0",
17
+ "pyyaml>=6.0.3",
18
+ ]
19
+
20
+ [project.scripts]
21
+ dirigent-mcp = "dirigent_mcp.server:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.0,<0.13.0"]
25
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ """An MCP server that lets an agent operate a dirigent instance through its API."""
@@ -0,0 +1,276 @@
1
+ """The MCP server: one dirigent instance, operated by an agent through the instance's own API.
2
+
3
+ Every tool is a call the Python client already makes, so what an agent may do is bounded by
4
+ the role of the token it was given, and nothing here deletes. Applying and running a document
5
+ is still the whole of the instance's reach: a pipeline the agent writes runs with every
6
+ connection the instance holds. Read-only, the server offers the reading tools alone.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from collections.abc import Awaitable, Callable
12
+ from datetime import timedelta
13
+ from typing import Any, Final
14
+
15
+ from mcp.server import MCPServer
16
+ from mcp.server.mcpserver.exceptions import ToolError
17
+ from mcp.types import ToolAnnotations
18
+ from pydantic import TypeAdapter
19
+
20
+ from dirigent_client import BlockKind, Dirigent, DirigentError, RunStatus
21
+ from dirigent_common import JsonMap
22
+
23
+ #: Where the server finds its instance and its token, the names `dg` reads them from.
24
+ URL_ENV: Final = "DG_URL"
25
+ TOKEN_ENV: Final = "DG_TOKEN"
26
+
27
+ #: Set to a true value, the server offers only the tools that read.
28
+ READ_ONLY_ENV: Final = "DG_MCP_READ_ONLY"
29
+
30
+ #: How many rows a listing answers with when the agent names no limit.
31
+ DEFAULT_LIMIT: Final = 50
32
+
33
+ #: The longest a run started with ``wait`` is waited on before its id is answered instead.
34
+ MAX_WAIT: Final = timedelta(minutes=10)
35
+
36
+ READS: Final = ToolAnnotations(read_only_hint=True, open_world_hint=False)
37
+ WRITES: Final = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
38
+ ANSWER: Final = TypeAdapter[Any](Any)
39
+
40
+ STOPS: Final = ToolAnnotations(read_only_hint=False, destructive_hint=True, open_world_hint=False)
41
+
42
+ INSTRUCTIONS: Final = """\
43
+ This server operates one dirigent instance: a data pipeline orchestrator whose pipelines are
44
+ YAML documents of steps, each step a block with a config. Before writing a document, read
45
+ the blocks it will use (list_blocks, get_block) and an example close to it (list_examples,
46
+ get_example); plan_document checks a document against the instance without storing it.
47
+ A run is started with run_pipeline and read with get_run, run_logs and run_attempts."""
48
+
49
+
50
+ class Clients:
51
+ """The client the tools share, opened on the first call so it lives on the server's loop."""
52
+
53
+ def __init__(self, open_client: Callable[[], Dirigent]) -> None:
54
+ """Hold how to open the client, not the client itself."""
55
+ self._open = open_client
56
+ self._client: Dirigent | None = None
57
+
58
+ def get(self) -> Dirigent:
59
+ """The client, opened now if no call has opened it yet."""
60
+ if self._client is None:
61
+ self._client = self._open()
62
+ return self._client
63
+
64
+
65
+ def build(open_client: Callable[[], Dirigent], *, read_only: bool = False) -> MCPServer:
66
+ """Build the server whose tools call the instance ``open_client`` connects to, or only its reading ones."""
67
+ server = MCPServer(name="dirigent", instructions=INSTRUCTIONS, log_level="WARNING")
68
+ clients = Clients(open_client)
69
+
70
+ def tool[F: Callable[..., Any]](annotations: ToolAnnotations) -> Callable[[F], F]:
71
+ """Register a tool, unless the server reads only and this tool writes."""
72
+ if read_only and annotations is not READS:
73
+ return lambda function: function
74
+ return server.tool(annotations=annotations)
75
+
76
+ async def call[T](work: Callable[[Dirigent], Awaitable[T]]) -> Any:
77
+ """Make one call, answering with its JSON, and a refusal as a tool error the agent reads."""
78
+ try:
79
+ answer = await work(clients.get())
80
+ except DirigentError as error:
81
+ raise ToolError(str(error)) from error
82
+ return _json(answer)
83
+
84
+ @tool(READS)
85
+ async def instance_info() -> Any:
86
+ """Describe the instance: its version, database, and how many blocks it has installed."""
87
+ return await call(lambda dg: dg.system.info())
88
+
89
+ @tool(READS)
90
+ async def list_pipelines(
91
+ tags: list[str] | None = None, after: str | None = None, limit: int = DEFAULT_LIMIT
92
+ ) -> Any:
93
+ """List the pipelines, optionally those carrying every tag given; pass `next` as `after` for more."""
94
+ return await call(lambda dg: dg.pipelines.list(tags=tags or (), after=after, limit=limit))
95
+
96
+ @tool(READS)
97
+ async def get_pipeline(code: str) -> Any:
98
+ """Read one pipeline: its current version, its document, and its schedules and sources."""
99
+ return await call(lambda dg: dg.pipelines.get(code))
100
+
101
+ @tool(READS)
102
+ async def export_pipeline(code: str, version: int | None = None) -> Any:
103
+ """Render a stored pipeline as the YAML document it was applied as, the current version unless one is named."""
104
+ return await call(lambda dg: dg.pipelines.export(code, version=version))
105
+
106
+ @tool(READS)
107
+ async def validate_pipeline(code: str) -> Any:
108
+ """Report what a stored pipeline would fail on if it ran now, such as a connection gone missing."""
109
+
110
+ async def checked(dg: Dirigent) -> Any:
111
+ return {"issues": await dg.pipelines.validate(code)}
112
+
113
+ return await call(checked)
114
+
115
+ @tool(READS)
116
+ async def plan_document(document: str) -> Any:
117
+ """Check a YAML pipeline or triggers document against the instance without storing it.
118
+
119
+ Answers what applying it would do, or every issue that refuses it.
120
+ """
121
+ return await call(lambda dg: dg.pipelines.apply(document, dry_run=True))
122
+
123
+ @tool(WRITES)
124
+ async def apply_document(document: str, pause_schedules: bool = False) -> Any:
125
+ """Store a YAML pipeline or triggers document as a new version; an unchanged one is left as it is."""
126
+ return await call(lambda dg: dg.pipelines.apply(document, pause_schedules=pause_schedules))
127
+
128
+ @tool(WRITES)
129
+ async def run_pipeline(
130
+ code: str,
131
+ params: JsonMap | None = None,
132
+ source: str | None = None,
133
+ input: Any = None,
134
+ wait: bool = False,
135
+ timeout_seconds: int = 60,
136
+ ) -> Any:
137
+ """Start a run of a pipeline with its parameters, or for a pipeline with sources the input it enters with.
138
+
139
+ With `wait`, answer once the run has settled or `timeout_seconds` has passed, whichever is first.
140
+ """
141
+
142
+ async def started(dg: Dirigent) -> Any:
143
+ accepted = await dg.pipelines.run(code, params=params, source=source, input=input)
144
+ if not wait or accepted.run_id is None:
145
+ return accepted
146
+ limit = min(timedelta(seconds=max(timeout_seconds, 1)), MAX_WAIT)
147
+ return await dg.runs.wait(accepted.run_id, timeout=limit)
148
+
149
+ return await call(started)
150
+
151
+ @tool(READS)
152
+ async def list_runs(
153
+ pipeline: str | None = None,
154
+ status: RunStatus | None = None,
155
+ since: str | None = None,
156
+ after: str | None = None,
157
+ limit: int = DEFAULT_LIMIT,
158
+ ) -> Any:
159
+ """List runs, newest first, narrowed to a pipeline, a status, or how far back (`since`, such as `24h`)."""
160
+ return await call(
161
+ lambda dg: dg.runs.list(pipeline=pipeline, status=status, since=since, after=after, limit=limit)
162
+ )
163
+
164
+ @tool(READS)
165
+ async def get_run(run_id: str) -> Any:
166
+ """Read one run: its status, every step's state, and its error when it failed."""
167
+ return await call(lambda dg: dg.runs.get(run_id))
168
+
169
+ @tool(READS)
170
+ async def run_attempts(
171
+ run_id: str, step: str | None = None, after: str | None = None, limit: int = DEFAULT_LIMIT
172
+ ) -> Any:
173
+ """List a run's attempts, one per try of a step, with each one's output, error and timing."""
174
+ return await call(lambda dg: dg.runs.attempts(run_id, step=step, after=after, limit=limit))
175
+
176
+ @tool(READS)
177
+ async def run_logs(
178
+ run_id: str, step: str | None = None, after: str | None = None, limit: int = DEFAULT_LIMIT
179
+ ) -> Any:
180
+ """Read a run's log lines in order, optionally one step's; pass `next` as `after` for more."""
181
+ return await call(lambda dg: dg.runs.logs(run_id, step=step, after=after, limit=limit))
182
+
183
+ @tool(STOPS)
184
+ async def cancel_run(run_id: str) -> Any:
185
+ """Cancel a run: what has not started never does, and running remote work is told to stop."""
186
+ return await call(lambda dg: dg.runs.cancel(run_id))
187
+
188
+ @tool(WRITES)
189
+ async def retry_attempt(attempt_id: str) -> Any:
190
+ """Retry one failed, skipped or cancelled attempt of a run, which reopens the run."""
191
+ return await call(lambda dg: dg.runs.retry(attempt_id))
192
+
193
+ @tool(READS)
194
+ async def list_blocks(kind: BlockKind | None = None) -> Any:
195
+ """List the installed blocks a step can use, each with its id, kind and one-line summary."""
196
+
197
+ async def listed(dg: Dirigent) -> Any:
198
+ catalog = await dg.blocks.catalog(kind=kind)
199
+ return {
200
+ "blocks": [
201
+ {"id": entry.id, "kind": entry.kind, "group": entry.group, "summary": entry.summary}
202
+ for entry in catalog.blocks
203
+ ]
204
+ }
205
+
206
+ return await call(listed)
207
+
208
+ @tool(READS)
209
+ async def get_block(block_id: str) -> Any:
210
+ """Read one block: the schema a step's `config` is checked against, and the schema of its output."""
211
+ return await call(lambda dg: dg.blocks.get(block_id))
212
+
213
+ @tool(READS)
214
+ async def list_examples(
215
+ shelf: str | None = None, tags: list[str] | None = None, after: str | None = None, limit: int = DEFAULT_LIMIT
216
+ ) -> Any:
217
+ """List the example documents the instance ships, optionally one shelf's or those with every tag given."""
218
+ return await call(lambda dg: dg.examples.list(shelf=shelf, tags=tags or (), after=after, limit=limit))
219
+
220
+ @tool(READS)
221
+ async def get_example(code: str) -> Any:
222
+ """Read one example, its document included, which is a working starting point for a new one."""
223
+ return await call(lambda dg: dg.examples.get(code))
224
+
225
+ @tool(READS)
226
+ async def list_sources(pipeline: str | None = None, after: str | None = None, limit: int = DEFAULT_LIMIT) -> Any:
227
+ """List the input sources, every pipeline's or one's, with each one's state and what it listens to."""
228
+ return await call(lambda dg: dg.sources.list(pipeline, after=after, limit=limit))
229
+
230
+ @tool(READS)
231
+ async def source_inputs(pipeline: str, code: str, after: str | None = None, limit: int = DEFAULT_LIMIT) -> Any:
232
+ """List what arrived at one source, newest first, each with its outcome and the run it started."""
233
+ return await call(lambda dg: dg.sources.inputs(pipeline, code, after=after, limit=limit))
234
+
235
+ @tool(WRITES)
236
+ async def pause_source(pipeline: str, code: str) -> Any:
237
+ """Pause a source: it takes no new input, and what is in flight finishes."""
238
+ return await call(lambda dg: dg.sources.pause(pipeline, code))
239
+
240
+ @tool(WRITES)
241
+ async def resume_source(pipeline: str, code: str) -> Any:
242
+ """Resume a paused source."""
243
+ return await call(lambda dg: dg.sources.resume(pipeline, code))
244
+
245
+ @tool(READS)
246
+ async def list_schedules(pipeline: str, after: str | None = None, limit: int = DEFAULT_LIMIT) -> Any:
247
+ """List a pipeline's schedules, each with its clock and when it next fires."""
248
+ return await call(lambda dg: dg.schedules.list(pipeline, after=after, limit=limit))
249
+
250
+ @tool(WRITES)
251
+ async def pause_schedule(pipeline: str, code: str) -> Any:
252
+ """Pause a schedule, so it fires no more until it is resumed."""
253
+ return await call(lambda dg: dg.schedules.pause(pipeline, code))
254
+
255
+ @tool(WRITES)
256
+ async def resume_schedule(pipeline: str, code: str) -> Any:
257
+ """Resume a paused schedule from its next firing."""
258
+ return await call(lambda dg: dg.schedules.resume(pipeline, code))
259
+
260
+ return server
261
+
262
+
263
+ def _json(value: Any) -> Any:
264
+ """Turn what the client answered into one JSON document, a model in its wire shape."""
265
+ return ANSWER.dump_python(value, mode="json", exclude_none=True)
266
+
267
+
268
+ def main() -> None:
269
+ """Serve over stdio, against the instance and with the token the environment names."""
270
+ url = os.environ.get(URL_ENV)
271
+ if not url:
272
+ sys.stderr.write(f"dirigent-mcp: set {URL_ENV} to the instance, and {TOKEN_ENV} to an API token for it\n")
273
+ raise SystemExit(2)
274
+ token = os.environ.get(TOKEN_ENV)
275
+ read_only = os.environ.get(READ_ONLY_ENV, "").strip().lower() in {"1", "true", "yes"}
276
+ build(lambda: Dirigent(url=url, token=token), read_only=read_only).run("stdio")