pullini-mcp 0.7.1__py3-none-any.whl

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
+ """pullini-mcp: a local stdio MCP connector for Pullini artifacts (E6, T6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.7.1"
pullini_mcp/client.py ADDED
@@ -0,0 +1,167 @@
1
+ """Thin HTTP client over the Pullini artifact REST API (E6, T6).
2
+
3
+ The connector runs on the agent's machine and talks to the Pullini host with a
4
+ personal access token sent as a bearer header (D-25). This module owns all HTTP
5
+ concerns so :mod:`pullini_mcp.server` only maps MCP tool calls to methods and
6
+ turns :class:`PulliniError` into a readable tool failure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ class PulliniError(Exception):
22
+ """A readable error for a transport failure or a non-2xx API response."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Config:
27
+ """Connection settings read from the environment (``PULLINI_URL``/``PULLINI_TOKEN``)."""
28
+
29
+ base_url: str
30
+ token: str
31
+ timeout: float = DEFAULT_TIMEOUT
32
+
33
+ @classmethod
34
+ def from_env(cls, env: dict[str, str] | None = None) -> Config:
35
+ """Build a config from ``PULLINI_URL`` and ``PULLINI_TOKEN``."""
36
+ source = os.environ if env is None else env
37
+ base_url = (source.get("PULLINI_URL") or "").strip().rstrip("/")
38
+ token = (source.get("PULLINI_TOKEN") or "").strip()
39
+ if not base_url:
40
+ raise PulliniError(
41
+ "PULLINI_URL is not set. Point it at your Pullini host, e.g. "
42
+ "https://pullini.example.com."
43
+ )
44
+ if not token:
45
+ raise PulliniError(
46
+ "PULLINI_TOKEN is not set. Create a token at /settings/tokens/ and "
47
+ "pass the raw secret."
48
+ )
49
+ return cls(base_url=base_url, token=token)
50
+
51
+
52
+ class PulliniClient:
53
+ """Minimal client for the endpoints that back the artifact API."""
54
+
55
+ def __init__(self, config: Config, *, transport: httpx.BaseTransport | None = None) -> None:
56
+ self._config = config
57
+ self._client = httpx.Client(
58
+ base_url=config.base_url,
59
+ headers={
60
+ "Authorization": f"Bearer {config.token}",
61
+ "Accept": "application/json",
62
+ },
63
+ timeout=config.timeout,
64
+ transport=transport,
65
+ )
66
+
67
+ def close(self) -> None:
68
+ self._client.close()
69
+
70
+ def __enter__(self) -> PulliniClient:
71
+ return self
72
+
73
+ def __exit__(self, *exc_info: object) -> None:
74
+ self.close()
75
+
76
+ # --- transport -----------------------------------------------------------
77
+
78
+ def _send(
79
+ self,
80
+ method: str,
81
+ path: str,
82
+ *,
83
+ json: dict | None = None,
84
+ headers: dict[str, str] | None = None,
85
+ ) -> httpx.Response:
86
+ try:
87
+ response = self._client.request(method, path, json=json, headers=headers)
88
+ except httpx.HTTPError as exc:
89
+ raise PulliniError(
90
+ f"Could not reach Pullini at {self._config.base_url}: {exc}"
91
+ ) from exc
92
+ if response.status_code >= 400:
93
+ raise self._error(response)
94
+ return response
95
+
96
+ def _request(self, method: str, path: str, *, json: dict | None = None) -> Any:
97
+ response = self._send(method, path, json=json)
98
+ if response.status_code == 204 or not response.content:
99
+ return None
100
+ return response.json()
101
+
102
+ def _error(self, response: httpx.Response) -> PulliniError:
103
+ try:
104
+ payload = response.json()
105
+ except ValueError:
106
+ payload = {}
107
+ code = payload.get("error")
108
+ detail = payload.get("detail") or response.reason_phrase or "Request failed."
109
+ message = (
110
+ f"{response.status_code} {code}: {detail}"
111
+ if code
112
+ else f"{response.status_code}: {detail}"
113
+ )
114
+ if response.status_code == 401:
115
+ message += " Check that PULLINI_TOKEN is valid and not expired or revoked."
116
+ if response.status_code == 404:
117
+ message += " The artifact may not exist or may belong to another user."
118
+ return PulliniError(message)
119
+
120
+ # --- endpoints -----------------------------------------------------------
121
+
122
+ def me(self) -> dict:
123
+ """Return the token owner and token metadata (``GET /api/me/``)."""
124
+ return self._request("GET", "/api/me/")
125
+
126
+ def skill(self) -> str:
127
+ """Return the agent skill Markdown (``GET /api/skill/``)."""
128
+ return self._send("GET", "/api/skill/", headers={"Accept": "text/markdown"}).text
129
+
130
+ def list_artifacts(self) -> list[dict]:
131
+ """Return the token owner's artifacts (``GET /api/artifacts/``)."""
132
+ payload = self._request("GET", "/api/artifacts/")
133
+ return payload.get("artifacts", [])
134
+
135
+ def get_artifact(self, slug: str) -> dict:
136
+ """Return one artifact with source Markdown and rendered HTML."""
137
+ return self._request("GET", f"/api/artifacts/{quote(slug)}/")
138
+
139
+ def create_artifact(self, title: str, content: str, visibility: str = "private") -> dict:
140
+ """Create an artifact (``POST /api/artifacts/``)."""
141
+ return self._request(
142
+ "POST",
143
+ "/api/artifacts/",
144
+ json={"title": title, "content": content, "visibility": visibility},
145
+ )
146
+
147
+ def update_artifact(
148
+ self, slug: str, *, title: str | None = None, content: str | None = None
149
+ ) -> dict:
150
+ """Partially update an artifact (``PATCH /api/artifacts/<slug>/``)."""
151
+ if title is None and content is None:
152
+ raise PulliniError("Provide a new title and/or content to update.")
153
+ body: dict[str, str] = {}
154
+ if title is not None:
155
+ body["title"] = title
156
+ if content is not None:
157
+ body["content"] = content
158
+ return self._request("PATCH", f"/api/artifacts/{quote(slug)}/", json=body)
159
+
160
+ def delete_artifact(self, slug: str) -> None:
161
+ """Delete an artifact (``DELETE /api/artifacts/<slug>/``)."""
162
+ self._request("DELETE", f"/api/artifacts/{quote(slug)}/")
163
+
164
+ def share_artifact(self, slug: str, *, enable: bool = True) -> dict | None:
165
+ """Enable or revoke the unlisted share link (``POST``/``DELETE .../share/``)."""
166
+ method = "POST" if enable else "DELETE"
167
+ return self._request(method, f"/api/artifacts/{quote(slug)}/share/")
pullini_mcp/server.py ADDED
@@ -0,0 +1,141 @@
1
+ """MCP stdio server exposing Pullini artifacts as MCP tools (E6, T6).
2
+
3
+ Every tool is a thin translation of an MCP call into a
4
+ :class:`~pullini_mcp.client.PulliniClient` request. Configuration comes from the
5
+ environment (``PULLINI_URL``/``PULLINI_TOKEN``, D-25), so the server holds no
6
+ state between calls and needs no ASGI/OAuth infrastructure on the Pullini host.
7
+
8
+ In ``mcp`` 2.x the server class formerly named ``FastMCP`` is ``MCPServer``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from importlib.resources import files
14
+
15
+ from mcp.server.mcpserver import MCPServer
16
+
17
+ from pullini_mcp import __version__
18
+ from pullini_mcp.client import Config, PulliniClient, PulliniError
19
+
20
+ mcp = MCPServer("pullini", version=__version__)
21
+
22
+ _VISIBILITIES = ("private", "unlisted")
23
+
24
+
25
+ def _client() -> PulliniClient:
26
+ return PulliniClient(Config.from_env())
27
+
28
+
29
+ def _check_visibility(visibility: str) -> str:
30
+ if visibility not in _VISIBILITIES:
31
+ raise PulliniError('"visibility" must be "private" or "unlisted".')
32
+ return visibility
33
+
34
+
35
+ @mcp.tool()
36
+ def store_artifact(
37
+ title: str,
38
+ content: str,
39
+ visibility: str = "private",
40
+ slug: str | None = None,
41
+ ) -> dict:
42
+ """Create an artifact, or overwrite the artifact identified by ``slug``.
43
+
44
+ Args:
45
+ title: Short human-readable title.
46
+ content: Markdown source; fenced ``mermaid`` blocks render as diagrams.
47
+ visibility: ``"private"`` (default) or ``"unlisted"`` (active share link).
48
+ slug: When given, overwrite that artifact instead of creating a new one.
49
+ """
50
+ _check_visibility(visibility)
51
+ client = _client()
52
+ try:
53
+ if slug:
54
+ artifact = client.update_artifact(slug, title=title, content=content)
55
+ if visibility == "unlisted" and not artifact.get("share_url"):
56
+ artifact = client.share_artifact(slug) or artifact
57
+ elif visibility == "private" and artifact.get("share_url"):
58
+ client.share_artifact(slug, enable=False)
59
+ artifact = client.get_artifact(slug)
60
+ return artifact
61
+ return client.create_artifact(title, content, visibility)
62
+ finally:
63
+ client.close()
64
+
65
+
66
+ @mcp.tool()
67
+ def list_artifacts() -> list[dict]:
68
+ """List the artifacts owned by the configured token."""
69
+ client = _client()
70
+ try:
71
+ return client.list_artifacts()
72
+ finally:
73
+ client.close()
74
+
75
+
76
+ @mcp.tool()
77
+ def get_artifact(slug: str) -> dict:
78
+ """Fetch one artifact: source Markdown, rendered HTML and plain text."""
79
+ client = _client()
80
+ try:
81
+ return client.get_artifact(slug)
82
+ finally:
83
+ client.close()
84
+
85
+
86
+ @mcp.tool()
87
+ def update_artifact(slug: str, title: str | None = None, content: str | None = None) -> dict:
88
+ """Change an artifact's title and/or content. The slug never changes."""
89
+ client = _client()
90
+ try:
91
+ return client.update_artifact(slug, title=title, content=content)
92
+ finally:
93
+ client.close()
94
+
95
+
96
+ @mcp.tool()
97
+ def delete_artifact(slug: str) -> dict:
98
+ """Delete an artifact by slug."""
99
+ client = _client()
100
+ try:
101
+ client.delete_artifact(slug)
102
+ return {"slug": slug, "deleted": True}
103
+ finally:
104
+ client.close()
105
+
106
+
107
+ @mcp.tool()
108
+ def share_artifact(slug: str, enable: bool = True) -> dict:
109
+ """Enable (``enable=True``) or revoke (``enable=False``) the unlisted share link.
110
+
111
+ Enabling returns the artifact with a ``share_url``; revoking clears it.
112
+ """
113
+ client = _client()
114
+ try:
115
+ if enable:
116
+ return client.share_artifact(slug) or {}
117
+ client.share_artifact(slug, enable=False)
118
+ return {"slug": slug, "share_url": None}
119
+ finally:
120
+ client.close()
121
+
122
+
123
+ @mcp.resource(
124
+ "pullini://skill",
125
+ name="pullini-skill",
126
+ title="Pullini artifacts skill",
127
+ description="Agent skill describing how to use the Pullini MCP artifact tools.",
128
+ mime_type="text/markdown",
129
+ )
130
+ def skill() -> str:
131
+ """Return the local agent skill describing the MCP tools."""
132
+ return files("pullini_mcp").joinpath("skill.md").read_text(encoding="utf-8")
133
+
134
+
135
+ def main() -> None:
136
+ """Run the stdio server (entry point for ``uvx pullini-mcp``)."""
137
+ mcp.run()
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
pullini_mcp/skill.md ADDED
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: pullini-artifacts
3
+ description: Use the Pullini MCP tools to create, read, update, delete and share user-owned Markdown artifacts (notes, reports, Mermaid diagrams) on a Pullini host. Use when persisting AI-generated documents.
4
+ ---
5
+
6
+ # Pullini artifacts (MCP)
7
+
8
+ Artifacts are user-owned Markdown documents (tables, code and `mermaid` diagrams
9
+ render). They are **private by default**. Use the tools below; the connector
10
+ handles the host URL and token for you — never ask the user for either.
11
+
12
+ ## Tools
13
+
14
+ - `store_artifact(title, content, visibility="private", slug=None)` — create, or
15
+ overwrite when `slug` is given.
16
+ - `list_artifacts()` — the artifacts you own.
17
+ - `get_artifact(slug)` — source Markdown, rendered HTML and plain text.
18
+ - `update_artifact(slug, title=None, content=None)` — change the title and/or content.
19
+ - `delete_artifact(slug)` — delete one.
20
+ - `share_artifact(slug, enable=True)` — enable/revoke the unlisted `share_url`.
21
+
22
+ ## Workflow
23
+
24
+ 1. Create with `store_artifact`; note the returned `slug` — it is **frozen**, so
25
+ later edits use the slug, not the title (renaming keeps the slug).
26
+ 2. Edit with `update_artifact(slug, ...)`.
27
+ 3. Share with `share_artifact`; anyone holding the `share_url` can read it.
28
+
29
+ ## Authoring
30
+
31
+ - `content` is Markdown; fenced ` ```mermaid ` blocks render as diagrams. Raw
32
+ HTML is sanitized.
33
+ - Keep `title` short; put structure in the Markdown headings.
34
+ - `visibility` is `"private"` (default) or `"unlisted"`.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: pullini-mcp
3
+ Version: 0.7.1
4
+ Summary: Local stdio MCP connector for Pullini artifacts (E6, T6).
5
+ Project-URL: Homepage, https://github.com/rykhalskyi/pullini
6
+ Project-URL: Repository, https://github.com/rykhalskyi/pullini
7
+ Project-URL: Issues, https://github.com/rykhalskyi/pullini/issues
8
+ Author-email: Iaroslav Rykhalskyi <rikhalskiy@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: agents,artifacts,fastmcp,mcp,pullini
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Documentation
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: mcp>=2
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pullini-mcp
27
+
28
+ A local **stdio** [MCP](https://modelcontextprotocol.io) connector that lets an
29
+ agent create, read, update, delete and share [Pullini](https://github.com/rykhalskyi/pullini)
30
+ artifacts over the authenticated artifact REST API.
31
+
32
+ The connector runs on the agent's machine. It reads its configuration from the
33
+ environment and needs no ASGI or OAuth infrastructure on the Pullini host
34
+ (decision D-25).
35
+
36
+ ## Install
37
+
38
+ Runs with [uv](https://docs.astral.sh/uv/), no install step required:
39
+
40
+ ```bash
41
+ uvx pullini-mcp
42
+ ```
43
+
44
+ Or install it into an environment:
45
+
46
+ ```bash
47
+ uv tool install pullini-mcp # or: pip install pullini-mcp
48
+ ```
49
+
50
+ ## Configure
51
+
52
+ Two environment variables:
53
+
54
+ | Variable | Description |
55
+ | --------------- | ------------------------------------------------------------------ |
56
+ | `PULLINI_URL` | Base URL of your Pullini host, e.g. `https://pullini.example.com`. |
57
+ | `PULLINI_TOKEN` | A personal access token created at `/settings/tokens/` (`pln_…`). |
58
+
59
+ The token is sent as `Authorization: Bearer <token>`; all resources are scoped to
60
+ the token's owner. Treat the raw secret like a password.
61
+
62
+ ### MCP client config
63
+
64
+ The client launches the server and passes the two variables. A copy-paste
65
+ example lives in [`mcp.example.json`](mcp.example.json).
66
+
67
+ **From a local checkout** (before the package is published): point `--directory`
68
+ at the `mcp/` folder of your clone.
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "pullini": {
74
+ "command": "uv",
75
+ "args": ["run", "--directory", "/path/to/pullini/mcp", "pullini-mcp"],
76
+ "env": {
77
+ "PULLINI_URL": "http://localhost:8000",
78
+ "PULLINI_TOKEN": "pln_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyy"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ **From PyPI** (once published):
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "pullini": {
91
+ "command": "uvx",
92
+ "args": ["pullini-mcp"],
93
+ "env": {
94
+ "PULLINI_URL": "https://pullini.example.com",
95
+ "PULLINI_TOKEN": "pln_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyy"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ## Tools
103
+
104
+ | Tool | What it does |
105
+ | ----------------- | -------------------------------------------------------------- |
106
+ | `store_artifact` | Create an artifact, or overwrite one when `slug` is given. |
107
+ | `list_artifacts` | List the token owner's artifacts. |
108
+ | `get_artifact` | Fetch one artifact (source Markdown, rendered HTML, plain text). |
109
+ | `update_artifact` | Change an artifact's title and/or content. |
110
+ | `delete_artifact` | Delete an artifact. |
111
+ | `share_artifact` | Enable or revoke the unlisted share link. |
112
+
113
+ `content` is Markdown; fenced ` ```mermaid ` blocks render as diagrams. Artifacts
114
+ are **private by default**: `share_artifact` returns a read-only `share_url`
115
+ anyone with the link can open, and revoking clears it.
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ uv sync
121
+ uv run ruff check .
122
+ uv run ruff format --check .
123
+ uv run pytest
124
+ ```
125
+
126
+ Tests mock the HTTP transport with `httpx.MockTransport`, so no Pullini host is
127
+ needed.
@@ -0,0 +1,8 @@
1
+ pullini_mcp/__init__.py,sha256=hnd1o_fbxCeU82UcrL4N-SMen4KBAT5Ibn5rb2ymfOM,165
2
+ pullini_mcp/client.py,sha256=LtGM6H9WA-aGNuTCKF1OgksxvNXtU8h3uPzUaT5spYs,6211
3
+ pullini_mcp/server.py,sha256=yFqRqpiks3bITvbtmrBfXuJzdVtYL2NXBjU7_talcZ4,4211
4
+ pullini_mcp/skill.md,sha256=joP2Yeb6LwXtCyj3bH73lK4vNsMsVUz3WEUcXnXpGeo,1483
5
+ pullini_mcp-0.7.1.dist-info/METADATA,sha256=jIXcvVZkAefNC9XfPL6zy_T8K35bQpk9WVtFf2GFzME,4188
6
+ pullini_mcp-0.7.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ pullini_mcp-0.7.1.dist-info/entry_points.txt,sha256=QndFgDk1j2WnF0H8YGebNfRAIfrgHXqas9xzFtZxfq8,56
8
+ pullini_mcp-0.7.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pullini-mcp = pullini_mcp.server:main