cellpy-mcp 0.1.0__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.
cellpy_mcp/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """An MCP server for cellpy — battery cell data, plots, and the cellpy API.
2
+
3
+ Four things a caller can do without writing Python: load cells and collect them
4
+ into frames; render figures and export data; ask what any cellpy call takes and
5
+ what its arguments mean; and set up a batch project from a template.
6
+
7
+ **This module is the contract `cellpy mcp` depends on.** cellpy ships a thin
8
+ command group (`cellpy mcp serve | install | status`) that imports this package
9
+ and, when it is absent, says how to install it. cellpy deliberately does not
10
+ depend on the MCP SDK — it is young and moving, and a long-lived network-facing
11
+ process is a security surface a data library should not carry — so the four
12
+ names below are load-bearing across a repository boundary. Changing them
13
+ without changing cellpy breaks the command:
14
+
15
+ __version__
16
+ serve(root=None)
17
+ install(root=None, client=None, dry_run=False) -> str
18
+ describe() -> dict
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = ["__version__", "serve", "install", "describe", "build_server", "Sandbox"]
28
+
29
+
30
+ def build_server(sandbox=None, state=None):
31
+ """An `MCPServer` with every tool and prompt registered."""
32
+ from .server import build_server as _build
33
+
34
+ return _build(sandbox=sandbox, state=state)
35
+
36
+
37
+ def serve(root: str | Path | None = None) -> None:
38
+ """Run the server over stdio. Blocks until the client disconnects.
39
+
40
+ Prints nothing: stdout *is* the protocol channel, and a friendly banner on
41
+ it is a parse error at the other end.
42
+ """
43
+ from .sandbox import Sandbox
44
+
45
+ build_server(Sandbox.from_environment(root)).run(transport="stdio")
46
+
47
+
48
+ def install(
49
+ root: str | Path | None = None,
50
+ client: str | None = None,
51
+ dry_run: bool = False,
52
+ ) -> str:
53
+ """Register this server with a chat client; return the path written."""
54
+ from .clients import install as _install
55
+ from .sandbox import Sandbox
56
+
57
+ return _install(Sandbox.from_environment(root).roots, client=client, dry_run=dry_run)
58
+
59
+
60
+ def describe() -> dict:
61
+ """What `cellpy mcp status` reports beyond the two version numbers."""
62
+ from .clients import config_path
63
+ from .sandbox import Sandbox
64
+
65
+ sandbox = Sandbox.from_environment()
66
+ described = {"roots": ", ".join(str(root) for root in sandbox.roots)}
67
+ try:
68
+ target = config_path()
69
+ except ValueError: # pragma: no cover - only if CLIENTS shrinks
70
+ return described
71
+ described["client config"] = f"{target}{'' if target.exists() else ' (not present)'}"
72
+ return described
73
+
74
+
75
+ def __getattr__(name: str):
76
+ # `Sandbox` is re-exported for callers who want to build a server with an
77
+ # explicit set of roots, but importing it eagerly would drag cellpy's
78
+ # config in just because someone imported the package.
79
+ if name == "Sandbox":
80
+ from .sandbox import Sandbox
81
+
82
+ return Sandbox
83
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
cellpy_mcp/__main__.py ADDED
@@ -0,0 +1,61 @@
1
+ """`python -m cellpy_mcp` — what a chat client spawns.
2
+
3
+ `cellpy mcp serve` is the discoverable spelling and goes through cellpy's shim.
4
+ This is the same thing without it, and it is what `install` writes into a client
5
+ config: naming the interpreter and the module directly is one fewer layer to be
6
+ wrong about when a GUI launches it from nowhere in particular.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="cellpy-mcp", description="MCP server for cellpy."
18
+ )
19
+ subcommands = parser.add_subparsers(dest="command")
20
+
21
+ serve = subcommands.add_parser("serve", help="run over stdio (the default)")
22
+ serve.add_argument("--root", help="a directory the server may read and write")
23
+
24
+ install = subcommands.add_parser("install", help="register with a chat client")
25
+ install.add_argument("--root", help="a directory the server may read and write")
26
+ install.add_argument("--client", help="which chat client to register with")
27
+ install.add_argument(
28
+ "--dry-run", action="store_true", help="print the target instead of writing"
29
+ )
30
+
31
+ subcommands.add_parser("status", help="report the roots and the client config")
32
+
33
+ args = parser.parse_args(argv)
34
+
35
+ from . import describe, install as do_install, serve as do_serve
36
+
37
+ # No subcommand means serve: a client config that says `-m cellpy_mcp` and
38
+ # nothing else must start a server, not print usage to the protocol channel.
39
+ if args.command in (None, "serve"):
40
+ do_serve(root=getattr(args, "root", None))
41
+ return 0
42
+
43
+ if args.command == "install":
44
+ try:
45
+ target = do_install(root=args.root, client=args.client, dry_run=args.dry_run)
46
+ except ValueError as exc:
47
+ print(exc, file=sys.stderr)
48
+ return 1
49
+ verb = "would register" if args.dry_run else "registered"
50
+ print(f"{verb} 'cellpy' in {target}")
51
+ if not args.dry_run:
52
+ print("restart the client to pick it up.")
53
+ return 0
54
+
55
+ for key, value in describe().items():
56
+ print(f"{key}: {value}")
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
cellpy_mcp/api.py ADDED
@@ -0,0 +1,406 @@
1
+ """Answering "how does this cellpy call work" from the installed package.
2
+
3
+ The observation this family exists for: people do not read API documentation.
4
+ No amount of rewriting fixes it, because the cost was never the reading — it is
5
+ knowing a page exists, finding it, and trusting that it describes the version
6
+ installed. A tool call removes all three.
7
+
8
+ What it cannot do is invent documentation that was never written. Measured over
9
+ the 44 calls in cellpy's own API reference against cellpy 2.1.3:
10
+
11
+ no docstring at all 3
12
+ one-line docstring 13
13
+ has an Args:/Parameters: section 14 of 44
14
+ parameters named in their own docstring 100 of 195 (51%)
15
+
16
+ `CellpyCell.get_cap` takes 23 arguments and documents none of them, so a tool
17
+ that returned the docstring and stopped would answer half its questions with a
18
+ sentence and a shrug — and, worse, let a model fill the silence. Three things
19
+ follow, and they are the design:
20
+
21
+ 1. The *signature* is always there. Names, annotations and especially defaults
22
+ survive when prose does not, so they are returned structured rather than
23
+ rendered into one string.
24
+ 2. `undocumented_parameters` is returned explicitly. Same principle as
25
+ `render`'s `trace_types`: the result carries the trap, so a model that would
26
+ otherwise guess at `categorical_column=` can see the package never said.
27
+ 3. `include_source` exists because a model reads Python well. It is the honest
28
+ fallback for the thin half, and the one thing a chat user cannot do for
29
+ themselves — they will not be grepping site-packages.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from typing import Any
36
+
37
+ from .sandbox import Refused
38
+
39
+ __all__ = ["register"]
40
+
41
+ #: Import is execution, and the caller is a language model acting on text it
42
+ #: may have read in a file. `describe_api("os.system")` must not become a way
43
+ #: to import arbitrary modules, so resolution is confined to these roots.
44
+ API_ROOTS = ("cellpy", "cellpycore")
45
+
46
+ #: Modules the index covers. Walking a package imports every submodule it finds
47
+ #: — including optional loaders whose third-party dependencies are absent — so
48
+ #: the list is written down rather than discovered.
49
+ API_MODULES = (
50
+ "cellpy",
51
+ "cellpy.collect",
52
+ "cellpy.config",
53
+ "cellpy.plotting",
54
+ "cellpy.plotting.registry",
55
+ "cellpy.readers.capacity_curves",
56
+ "cellpy.readers.cellreader",
57
+ "cellpy.exporters.tabular",
58
+ "cellpy.utils.batch",
59
+ "cellpy.utils.example_data",
60
+ "cellpy.utils.helpers",
61
+ "cellpy.utils.ica",
62
+ )
63
+
64
+ #: Docstrings can be long (`Collection.plot` is 24 lines) and a tool result is
65
+ #: context. Long enough for an Args: section, short enough not to be the reply.
66
+ MAX_DOC_CHARS = 4000
67
+ MAX_SOURCE_LINES = 200
68
+
69
+ #: ``See :func:`cellpy.readers.capacity_curves.get_cap` `` — cellpy 2.1.3 and
70
+ #: earlier, and anything written to Sphinx conventions.
71
+ _ROLE_REFERENCE = re.compile(r":(?:func|meth|obj|class):`~?([\w.]+)`")
72
+
73
+ #: ``See `get_cap`.`` — what cellpy 2.1.3.post2 leaves behind. The negative
74
+ #: lookbehind keeps this from also matching the tail of a role reference.
75
+ _BARE_REFERENCE = re.compile(r"(?<![:`\w])`~?([\w.]+)`")
76
+
77
+
78
+ def _is_class_member(path: str) -> bool:
79
+ """True for `module.Class.method`, false for `module.function`.
80
+
81
+ cellpy has no capitalised module names, so a capitalised segment before the
82
+ last one means the entry hangs off a class.
83
+ """
84
+ parts = path.split(".")
85
+ return len(parts) > 1 and parts[-2][:1].isupper()
86
+
87
+
88
+ def resolve_dotted(path: str) -> Any:
89
+ """Import as far as the path allows, then getattr the rest — inside API_ROOTS."""
90
+ import importlib
91
+
92
+ if path.split(".")[0] not in API_ROOTS:
93
+ joined = " and ".join(API_ROOTS)
94
+ raise Refused(f"{path!r} is not part of cellpy. This tool only describes {joined}.")
95
+
96
+ parts = path.split(".")
97
+ for split in range(len(parts), 0, -1):
98
+ try:
99
+ obj = importlib.import_module(".".join(parts[:split]))
100
+ except ImportError:
101
+ continue
102
+ try:
103
+ for attr in parts[split:]:
104
+ obj = getattr(obj, attr)
105
+ except AttributeError:
106
+ continue
107
+ return obj
108
+ raise Refused(f"Nothing called {path!r} in cellpy.")
109
+
110
+
111
+ def follow_reference(doc: str, index: list[dict] | None = None) -> tuple[str | None, str]:
112
+ """Resolve a cross-reference in `doc` to the docstring it points at.
113
+
114
+ This is the single biggest win in the family, and it is worth saying why.
115
+ `CellpyCell.get_cap` takes 23 arguments, documents none of them, and spends
116
+ its whole docstring pointing at `cellpy.readers.capacity_curves.get_cap`.
117
+ The delegate documents 22 of its 24 in a full ``Args:`` block. `to_csv`
118
+ (9 arguments) and `to_excel` (7) are the same shape.
119
+
120
+ So the documentation is not missing — it is one hop away, behind a marker
121
+ that only a docs *site* resolves. Everywhere a docstring is actually met —
122
+ an IDE tooltip, `help()`, a chat window, an agent — the reader gets the
123
+ pointer and not the text.
124
+
125
+ **Two spellings, because cellpy changed one.** Up to 2.1.3 these were
126
+ Sphinx roles: ``See :func:`cellpy.readers.capacity_curves.get_cap```. In
127
+ 2.1.3.post2 the roles were stripped to stop them leaking into the rendered
128
+ API docs, which also stripped the module path — `get_cap`'s docstring is
129
+ now ``See `get_cap`.`` So a bare name is resolved through the index, and
130
+ accepted only when it lands on a *different* object with its own docstring.
131
+ `delegates_to` is always returned alongside `delegate_doc`, so the caller
132
+ can see exactly which call the text came from rather than trusting a guess.
133
+ """
134
+ import inspect
135
+
136
+ candidates: list[str] = []
137
+ role = _ROLE_REFERENCE.search(doc)
138
+ if role:
139
+ candidates.append(role.group(1))
140
+ candidates.extend(_BARE_REFERENCE.findall(doc))
141
+
142
+ for target in candidates:
143
+ if "." in target:
144
+ if target.split(".")[0] not in API_ROOTS:
145
+ continue
146
+ try:
147
+ referenced = resolve_dotted(target)
148
+ except Exception: # noqa: BLE001 - a dead reference is not an error
149
+ continue
150
+ path = target
151
+ else:
152
+ # A bare name, and the index is the only thing that can say what it
153
+ # means. Restricted to *module-level* functions, which is both the
154
+ # shape cellpy's delegation actually takes — a thin method calling
155
+ # the real implementation — and what keeps `get_cap` from matching
156
+ # the very method whose docstring we are reading.
157
+ matches = [
158
+ entry
159
+ for entry in (index or [])
160
+ if entry["name"] == target and not _is_class_member(entry["path"])
161
+ ]
162
+ if len(matches) != 1:
163
+ # Nothing, or still ambiguous. Guessing between two calls would
164
+ # attach someone else's arguments to this one.
165
+ continue
166
+ path = matches[0]["path"]
167
+ try:
168
+ referenced = resolve_dotted(path)
169
+ except Exception: # noqa: BLE001
170
+ continue
171
+
172
+ referenced_doc = inspect.getdoc(referenced) or ""
173
+ # A reference resolving back to the same text says nothing.
174
+ if not referenced_doc or referenced_doc == doc:
175
+ continue
176
+ return path, referenced_doc
177
+
178
+ return None, ""
179
+
180
+
181
+ def summarise(doc: str) -> str:
182
+ """The first line of a docstring that says something.
183
+
184
+ `CellpyCell.set_mass` opens with a bare ``Warning:``, so taking line one
185
+ verbatim produces an index in which several unrelated calls are described
186
+ as "Warning:". A heading is carried into the line below it instead.
187
+ """
188
+ lines = [line.strip() for line in doc.strip().splitlines() if line.strip()]
189
+ if not lines:
190
+ return ""
191
+ first = lines[0]
192
+ if first.endswith(":") and len(first) < 24 and len(lines) > 1:
193
+ return f"{first} {lines[1]}"
194
+ return first
195
+
196
+
197
+ def build_index() -> list[dict]:
198
+ """Every public callable in API_MODULES, one path each.
199
+
200
+ Re-exports are the norm in cellpy: `get` is reachable as `cellpy.get` and
201
+ `cellpy.readers.cellreader.get`, and `utils.helpers` re-exports
202
+ `CellpyCell` — which is how the first version of this answered "get_cap"
203
+ with `cellpy.utils.helpers.CellpyCell.get_cap`, a true path that sends the
204
+ reader to the wrong file. So index by object identity and keep the shortest
205
+ path, because that is the one people type, breaking ties toward the module
206
+ that actually defines it.
207
+ """
208
+ import importlib
209
+ import inspect
210
+
211
+ best: dict[int, tuple[tuple[int, int], str, Any]] = {}
212
+
213
+ def offer(path: str, entity: Any) -> None:
214
+ home = getattr(entity, "__module__", "") or ""
215
+ rank = (len(path.split(".")), 0 if path.startswith(home) else 1)
216
+ current = best.get(id(entity))
217
+ if current is None or rank < current[0]:
218
+ best[id(entity)] = (rank, path, entity)
219
+
220
+ for module_name in API_MODULES:
221
+ try:
222
+ module = importlib.import_module(module_name)
223
+ except Exception: # noqa: BLE001 - a module that will not import is not an error
224
+ continue
225
+ for name, obj in vars(module).items():
226
+ if name.startswith("_") or not callable(obj):
227
+ continue
228
+ # Anything that merely passed through from outside cellpy (pandas,
229
+ # pathlib) is not ours to describe.
230
+ if not (getattr(obj, "__module__", "") or "").startswith(API_ROOTS):
231
+ continue
232
+ offer(f"{module_name}.{name}", obj)
233
+ if isinstance(obj, type):
234
+ for attr in vars(obj):
235
+ member = getattr(obj, attr, None)
236
+ if not attr.startswith("_") and callable(member):
237
+ offer(f"{module_name}.{name}.{attr}", member)
238
+
239
+ entries = [
240
+ {"path": path, "name": path.rsplit(".", 1)[-1], "summary": summarise(inspect.getdoc(entity) or "")}
241
+ for _rank, path, entity in best.values()
242
+ ]
243
+ entries.sort(key=lambda entry: entry["path"])
244
+ return entries
245
+
246
+
247
+ def register(server, state) -> None:
248
+ def index() -> list[dict]:
249
+ if state.api_index is None:
250
+ state.api_index = build_index()
251
+ return state.api_index
252
+
253
+ @server.tool()
254
+ def search_api(query: str, limit: int = 15) -> dict:
255
+ """Find cellpy calls by name, or by what their first docstring line says.
256
+
257
+ Use this when you know the task but not the call — "average cycles",
258
+ "loading", "mass". `describe_api` then gives the arguments.
259
+ """
260
+ if not query.strip():
261
+ raise Refused("Give something to search for.")
262
+ needle = query.strip().lower()
263
+ limit = max(1, min(int(limit), 50))
264
+
265
+ scored: list[tuple[int, dict]] = []
266
+ for entry in index():
267
+ name = entry["name"].lower()
268
+ if needle == name:
269
+ score = 0
270
+ elif needle in name:
271
+ score = 1
272
+ elif needle in entry["path"].lower():
273
+ score = 2
274
+ elif needle in entry["summary"].lower():
275
+ score = 3
276
+ else:
277
+ continue
278
+ scored.append((score, entry))
279
+
280
+ scored.sort(key=lambda pair: (pair[0], len(pair[1]["path"])))
281
+ return {
282
+ "query": query,
283
+ "indexed": len(index()),
284
+ "matches": [entry for _score, entry in scored[:limit]],
285
+ "truncated": len(scored) > limit,
286
+ }
287
+
288
+ @server.tool()
289
+ def describe_api(name: str, include_source: bool = False) -> dict:
290
+ """What a cellpy call takes and what it does, from the installed package.
291
+
292
+ `name` is a dotted path (`cellpy.get`, `cellpy.collect.collect_summary`)
293
+ or a bare name (`get_cap`) looked up in the index.
294
+
295
+ Read `undocumented_parameters` before answering a question about one of
296
+ them. cellpy documents roughly half its arguments, so an argument
297
+ missing from `doc` means the package never said what it does — not that
298
+ it does not matter. Ask again with `include_source=True` rather than
299
+ guessing.
300
+
301
+ When `delegates_to` is set, the docstring pointed at another call with a
302
+ Sphinx reference and `delegate_doc` is where the arguments are actually
303
+ described — read it, it is usually the real documentation.
304
+ """
305
+ import inspect
306
+
307
+ if "." in name:
308
+ # A dotted path is a request to import. `resolve_dotted` is the
309
+ # guard, so send every dotted name through it rather than falling
310
+ # back to the index and reporting "no such cellpy call" for
311
+ # `os.system` — true, but the wrong reason, and the wrong reason
312
+ # teaches the caller that a different spelling might work.
313
+ path = name
314
+ obj = resolve_dotted(path)
315
+ else:
316
+ matches = [entry for entry in index() if entry["name"] == name]
317
+ if not matches:
318
+ hits = search_api(name, limit=5)["matches"]
319
+ suffix = ""
320
+ if hits:
321
+ suffix = " Did you mean: " + ", ".join(h["path"] for h in hits) + "?"
322
+ raise Refused(f"No cellpy call called {name!r}.{suffix}")
323
+ # Prefer how people actually call it. `get_cap` names both
324
+ # `CellpyCell.get_cap` and the module-level `capacity_curves.get_cap`
325
+ # it delegates to; someone asking about "get_cap" means the one they
326
+ # would write as `cell.get_cap(...)`, and the delegate's arguments
327
+ # arrive anyway through `delegates_to`.
328
+ matches.sort(key=lambda entry: (not _is_class_member(entry["path"]), len(entry["path"])))
329
+ path = matches[0]["path"]
330
+ obj = resolve_dotted(path)
331
+
332
+ doc = inspect.getdoc(obj) or ""
333
+ delegate_path, delegate_doc = follow_reference(doc, index())
334
+ # A parameter counts as documented if *either* docstring names it.
335
+ searchable = doc + "\n" + delegate_doc
336
+
337
+ if isinstance(obj, type):
338
+ kind = "class"
339
+ elif isinstance(obj, property):
340
+ kind = "property"
341
+ else:
342
+ kind = "function"
343
+
344
+ target = obj.fget if isinstance(obj, property) else obj
345
+ parameters: list[dict] = []
346
+ signature_text = None
347
+ try:
348
+ signature = inspect.signature(target)
349
+ except (TypeError, ValueError):
350
+ signature = None
351
+
352
+ if signature is not None:
353
+ # Methods are shown as you would call them. Leaving `self` in the
354
+ # rendered signature invites a model to pass it as an argument.
355
+ rendered = str(signature)
356
+ if rendered.startswith("(self, "):
357
+ rendered = "(" + rendered[len("(self, ") :]
358
+ elif rendered == "(self)":
359
+ rendered = "()"
360
+ signature_text = f"{path.rsplit('.', 1)[-1]}{rendered}"
361
+
362
+ empty = inspect.Parameter.empty
363
+ for pname, parameter in signature.parameters.items():
364
+ if pname in ("self", "cls"):
365
+ continue
366
+ variadic = (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD)
367
+ parameters.append(
368
+ {
369
+ "name": pname,
370
+ "annotation": (
371
+ None if parameter.annotation is empty else str(parameter.annotation)
372
+ ),
373
+ "default": (
374
+ None if parameter.default is empty else repr(parameter.default)
375
+ ),
376
+ "required": parameter.default is empty
377
+ and parameter.kind not in variadic,
378
+ "documented": bool(re.search(rf"\b{re.escape(pname)}\b", searchable)),
379
+ }
380
+ )
381
+
382
+ result: dict[str, Any] = {
383
+ "path": path,
384
+ "kind": kind,
385
+ "module": getattr(obj, "__module__", None),
386
+ "signature": signature_text,
387
+ "parameters": parameters,
388
+ "doc": doc[:MAX_DOC_CHARS] + ("…" if len(doc) > MAX_DOC_CHARS else ""),
389
+ # The trap, carried in the result rather than left to be discovered.
390
+ "undocumented_parameters": [p["name"] for p in parameters if not p["documented"]],
391
+ }
392
+ if delegate_path:
393
+ result["delegates_to"] = delegate_path
394
+ result["delegate_doc"] = delegate_doc[:MAX_DOC_CHARS] + (
395
+ "…" if len(delegate_doc) > MAX_DOC_CHARS else ""
396
+ )
397
+ if include_source:
398
+ try:
399
+ source = inspect.getsource(target)
400
+ except (OSError, TypeError) as exc:
401
+ result["source_error"] = str(exc)
402
+ else:
403
+ lines = source.splitlines()
404
+ result["source"] = "\n".join(lines[:MAX_SOURCE_LINES])
405
+ result["source_truncated"] = len(lines) > MAX_SOURCE_LINES
406
+ return result