prodc 0.7.4__tar.gz → 0.8.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.
- {prodc-0.7.4 → prodc-0.8.0}/CHANGELOG.md +11 -0
- {prodc-0.7.4 → prodc-0.8.0}/PKG-INFO +6 -1
- {prodc-0.7.4 → prodc-0.8.0}/README.md +3 -0
- {prodc-0.7.4 → prodc-0.8.0}/pyproject.toml +3 -1
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/__init__.py +1 -1
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/agent.md +7 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/app.py +11 -2
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/common.py +8 -1
- prodc-0.8.0/src/prodc/cli/mcp.py +132 -0
- prodc-0.8.0/src/prodc/mcp_config.py +147 -0
- prodc-0.8.0/src/prodc/mcp_server.py +267 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/writer.py +10 -0
- prodc-0.8.0/tests/test_edit_sha.py +46 -0
- prodc-0.8.0/tests/test_mcp_install.py +115 -0
- prodc-0.8.0/tests/test_mcp_server.py +117 -0
- {prodc-0.7.4 → prodc-0.8.0}/.gitignore +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/LICENSE +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/adapters.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/anchor.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/board.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/callbacks.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/__init__.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/model.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/ux.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/visual.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/__main__.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/commands.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/render.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/config.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/dedup.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/glossary.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/guide.md +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/inbox.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/issues.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/loader.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/model.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/py.typed +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report_export.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report_html.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/status.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/storybook.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/src/prodc/views.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/fixtures/storybook/index.json +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_adapters.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_anchor.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_answer.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_attest_policy.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_awaits.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_callbacks.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_capture_block_scalar.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_add.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_lint.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_registry.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_citation.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_claim_gating.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_cli.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_command_hook.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_confidence.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_config_keys.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_contradicts.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_decisions.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_declared_only.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_dedup.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_dedup_candidates.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_description.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_drafts.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_evalgate.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_expects.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_external_adapter.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_files_key.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_flowsteps.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_gitlab_source.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_glossary.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_importance.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_inbox.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_inventory.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_issues.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_lint_decision.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_loader.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_models.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_needref.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_path.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_path_views.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_playwright.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_principle_withdrawal.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_principles.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_relink.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_report.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_report_export.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_report_html.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_results_reports.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_review.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_roles.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_source_role.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_sources.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_stage.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_staleness.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_status.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_storybook.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_suggest.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_suggest_eval.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_ux_catalog.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_variants.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_view_links.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_views.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_views_report.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_visual_catalog.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_vitest.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_who_variants.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_withdrawn.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_writer.py +0 -0
- {prodc-0.7.4 → prodc-0.8.0}/tests/test_writer_comments.py +0 -0
|
@@ -4,6 +4,17 @@ All notable changes are documented here. Versioning is [SemVer](https://semver.o
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `prodc mcp serve`: prodc as an MCP server, with the optional extra `pip install 'prodc[mcp]'`.
|
|
12
|
+
10 read tools and 5 write tools. `capture` and `add_need` refuse `primary`. `accept`,
|
|
13
|
+
`outcome`, `attest`, `withdraw` and `sync` are not exposed. See [docs/mcp.md](docs/mcp.md).
|
|
14
|
+
- `prodc mcp install` writes the server into `.mcp.json` (or `--client cursor|vscode`). It changes
|
|
15
|
+
only the `prodc` entry and refuses a file it cannot parse. `prodc mcp uninstall` removes it.
|
|
16
|
+
- A write's `--json` output has `sha_before` and `sha_after`, to pass as `--expect-sha`.
|
|
17
|
+
|
|
7
18
|
## [0.7.4] - 2026-11-11
|
|
8
19
|
|
|
9
20
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Product management as code: roles, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/cardona-oss/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/cardona-oss/prodc
|
|
@@ -24,6 +24,8 @@ Requires-Dist: pyyaml>=6
|
|
|
24
24
|
Provides-Extra: docs
|
|
25
25
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
26
26
|
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
27
|
+
Provides-Extra: mcp
|
|
28
|
+
Requires-Dist: mcp<3,>=2; extra == 'mcp'
|
|
27
29
|
Description-Content-Type: text/markdown
|
|
28
30
|
|
|
29
31
|
# prodc
|
|
@@ -196,6 +198,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
|
|
|
196
198
|
- `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
|
|
197
199
|
manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
|
|
198
200
|
user's own words. `prodc guide` is a short guide for people.
|
|
201
|
+
- `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
|
|
202
|
+
Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
|
|
203
|
+
[MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
|
|
199
204
|
|
|
200
205
|
prodc also has goals, releases, roles, principles, and decisions
|
|
201
206
|
([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
|
|
@@ -168,6 +168,9 @@ The Python modules are not a public API. Use the CLI, `--json` and the YAML file
|
|
|
168
168
|
- `prodc agent >> AGENTS.md` adds instructions for a coding agent to act as the project's product
|
|
169
169
|
manager. An agent may record `assumption` or `secondary` sources; `primary` is only for a real
|
|
170
170
|
user's own words. `prodc guide` is a short guide for people.
|
|
171
|
+
- `prodc mcp install` registers prodc as an MCP server, so an agent in Claude Code, Cursor, or VS
|
|
172
|
+
Code uses it through tools. Install with `pip install 'prodc[mcp]'`. See
|
|
173
|
+
[MCP server](https://cardona-oss.gitlab.io/prodc/mcp/).
|
|
171
174
|
|
|
172
175
|
prodc also has goals, releases, roles, principles, and decisions
|
|
173
176
|
([model docs](https://cardona-oss.gitlab.io/prodc/model/)), and the commands `dupes`,
|
|
@@ -31,6 +31,8 @@ dependencies = ["pyyaml>=6"]
|
|
|
31
31
|
prodc = "prodc.cli:main"
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
|
+
# mcp: the optional MCP server (`prodc mcp serve`). v2 moved the server import, so pin the major.
|
|
35
|
+
mcp = ["mcp>=2,<3"]
|
|
34
36
|
# docs are an *extra* (not a uv dependency-group): CI's `pages` job and moon's `docs` task
|
|
35
37
|
# install them with `--extra docs` to build the GitLab Pages site
|
|
36
38
|
docs = [
|
|
@@ -68,7 +70,7 @@ include = ["src", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
|
68
70
|
|
|
69
71
|
[dependency-groups]
|
|
70
72
|
# musil is a dev-only model checker (tests/test_models.py); prodc's runtime stays pyyaml-only.
|
|
71
|
-
dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35"]
|
|
73
|
+
dev = ["ruff>=0.6", "pyright[nodejs]>=1.1.390", "pytest>=8", "types-pyyaml>=6", "musil>=0.35", "mcp>=2,<3"]
|
|
72
74
|
|
|
73
75
|
[tool.ruff]
|
|
74
76
|
line-length = 100
|
|
@@ -132,6 +132,13 @@ boards under), and `prodc diff <snapshot.json>` (what changed since a
|
|
|
132
132
|
saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
|
|
133
133
|
`--wide`, `--include-drafts`, and `--stale <days>`.
|
|
134
134
|
|
|
135
|
+
## As MCP tools
|
|
136
|
+
|
|
137
|
+
If your client has the prodc MCP server (`prodc mcp install`), the same commands are tools:
|
|
138
|
+
`status`, `show`, `plan`, `lint`, `capture`, `set`, `add_need`, and others. The rules above
|
|
139
|
+
still hold. `capture` and `add_need` accept only `secondary` and `assumption`. Chain writes by
|
|
140
|
+
passing the last `sha_after` as `expect_sha`.
|
|
141
|
+
|
|
135
142
|
## Running non-interactively (CI, or no human in the loop)
|
|
136
143
|
|
|
137
144
|
- **Exit codes are the contract, not the stdout.** `0` = clean; `1` = a gate failed (a BROKEN
|
|
@@ -41,9 +41,11 @@ from .commands import (
|
|
|
41
41
|
cmd_views,
|
|
42
42
|
cmd_withdraw,
|
|
43
43
|
)
|
|
44
|
+
from .mcp import add_mcp_parser
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
"""Return the full ``prodc`` argument parser. The MCP server builds its argv against it."""
|
|
47
49
|
parser = argparse.ArgumentParser(prog="prodc", description="Product management as code.")
|
|
48
50
|
parser.add_argument("--version", action="version", version=f"prodc {__version__}")
|
|
49
51
|
parser.add_argument(
|
|
@@ -752,6 +754,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
752
754
|
)
|
|
753
755
|
sy.set_defaults(func=cmd_sync)
|
|
754
756
|
|
|
757
|
+
add_mcp_parser(
|
|
758
|
+
sub.add_parser("mcp", help="run prodc as an MCP server, or register it with a client")
|
|
759
|
+
)
|
|
760
|
+
|
|
755
761
|
ag = sub.add_parser(
|
|
756
762
|
"agent",
|
|
757
763
|
help="print prodc's agent playbook (teach a coding agent to run prodc as the project's PM)",
|
|
@@ -784,8 +790,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
784
790
|
)
|
|
785
791
|
df.add_argument("snapshot", help="path to a saved `prodc status --json` file")
|
|
786
792
|
df.set_defaults(func=cmd_diff)
|
|
793
|
+
return parser
|
|
787
794
|
|
|
788
|
-
|
|
795
|
+
|
|
796
|
+
def main(argv: list[str] | None = None) -> int:
|
|
797
|
+
args = build_parser().parse_args(argv)
|
|
789
798
|
if args.chdir is not None:
|
|
790
799
|
try:
|
|
791
800
|
os.chdir(args.chdir)
|
|
@@ -79,7 +79,14 @@ def exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) ->
|
|
|
79
79
|
|
|
80
80
|
def emit_edit(edit: Edit, dry_run: bool, as_json: bool) -> int:
|
|
81
81
|
if as_json:
|
|
82
|
-
|
|
82
|
+
payload = {
|
|
83
|
+
"file": edit.path.name,
|
|
84
|
+
"dry_run": dry_run,
|
|
85
|
+
"diff": edit.diff,
|
|
86
|
+
"sha_before": edit.sha_before,
|
|
87
|
+
"sha_after": edit.sha_after,
|
|
88
|
+
}
|
|
89
|
+
print(json.dumps(payload, indent=2))
|
|
83
90
|
else:
|
|
84
91
|
print(f"{'would edit' if dry_run else 'edited'} {edit.path.name}")
|
|
85
92
|
print(edit.diff, end="")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``prodc mcp``: run the MCP server, or register it in an MCP client's project config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from ..mcp_config import (
|
|
13
|
+
CLIENTS,
|
|
14
|
+
DEFAULT_COMMAND,
|
|
15
|
+
ClientName,
|
|
16
|
+
McpConfigError,
|
|
17
|
+
apply,
|
|
18
|
+
install,
|
|
19
|
+
manual_instructions,
|
|
20
|
+
uninstall,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_mcp_parser(mp: argparse.ArgumentParser) -> None:
|
|
25
|
+
"""Fill ``prodc mcp {serve,install,uninstall}``. A bare ``prodc mcp`` prints help, exit 2."""
|
|
26
|
+
|
|
27
|
+
def bare(_args: argparse.Namespace) -> int:
|
|
28
|
+
mp.print_help(sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
mp.set_defaults(func=bare)
|
|
32
|
+
msub = mp.add_subparsers(dest="mcp_command")
|
|
33
|
+
|
|
34
|
+
sv = msub.add_parser("serve", help="run the MCP server on stdio (the client starts this)")
|
|
35
|
+
sv.set_defaults(func=cmd_mcp_serve)
|
|
36
|
+
|
|
37
|
+
for name, func, verb in (
|
|
38
|
+
("install", cmd_mcp_install, "add the prodc server to"),
|
|
39
|
+
("uninstall", cmd_mcp_uninstall, "remove the prodc server from"),
|
|
40
|
+
):
|
|
41
|
+
p = msub.add_parser(name, help=f"{verb} an MCP client's project config")
|
|
42
|
+
p.add_argument(
|
|
43
|
+
"--client",
|
|
44
|
+
choices=["claude", "cursor", "vscode", "codex"],
|
|
45
|
+
default="claude",
|
|
46
|
+
help="which client's config (default: claude, .mcp.json)",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--scope",
|
|
50
|
+
choices=["project", "user"],
|
|
51
|
+
default="project",
|
|
52
|
+
help="project writes the repo's config file; user prints the client's own command",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--dry-run", action="store_true", help="show the diff, write nothing")
|
|
55
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
56
|
+
if name == "install":
|
|
57
|
+
p.add_argument(
|
|
58
|
+
"--command",
|
|
59
|
+
default=None,
|
|
60
|
+
metavar="CMD",
|
|
61
|
+
help='the server command to write (default: uvx --from "prodc[mcp]" prodc mcp '
|
|
62
|
+
"serve). Use 'uv run prodc mcp serve' when the project pins prodc",
|
|
63
|
+
)
|
|
64
|
+
p.set_defaults(func=func)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_mcp_serve(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Run the stdio server. Needs the ``mcp`` extra."""
|
|
69
|
+
try:
|
|
70
|
+
from ..mcp_server import serve
|
|
71
|
+
except ImportError:
|
|
72
|
+
print("prodc: install prodc[mcp] to run the MCP server", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
serve(args.chdir)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _report(edit_path: Path, old: str, new: str, dry_run: bool, as_json: bool) -> None:
|
|
79
|
+
changed = old != new
|
|
80
|
+
diff = "".join(
|
|
81
|
+
difflib.unified_diff(
|
|
82
|
+
old.splitlines(keepends=True),
|
|
83
|
+
new.splitlines(keepends=True),
|
|
84
|
+
fromfile=f"a/{edit_path}",
|
|
85
|
+
tofile=f"b/{edit_path}",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if as_json:
|
|
89
|
+
print(
|
|
90
|
+
json.dumps(
|
|
91
|
+
{"file": str(edit_path), "changed": changed, "dry_run": dry_run, "diff": diff},
|
|
92
|
+
indent=2,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
if not changed:
|
|
97
|
+
print(f"{edit_path}: unchanged")
|
|
98
|
+
return
|
|
99
|
+
print(f"{'would edit' if dry_run else 'edited'} {edit_path}")
|
|
100
|
+
print(diff, end="")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run(args: argparse.Namespace, installing: bool) -> int:
|
|
104
|
+
client = cast(ClientName, args.client)
|
|
105
|
+
command: str = getattr(args, "command", None) or DEFAULT_COMMAND
|
|
106
|
+
if client not in CLIENTS or args.scope == "user":
|
|
107
|
+
# prodc does not write user-scope files (~/.claude.json is live client state) or TOML.
|
|
108
|
+
if installing:
|
|
109
|
+
print(manual_instructions(client, args.scope, command), end="")
|
|
110
|
+
else:
|
|
111
|
+
print(f"remove the 'prodc' server with {client}'s own MCP command or config")
|
|
112
|
+
return 0
|
|
113
|
+
root = Path.cwd()
|
|
114
|
+
try:
|
|
115
|
+
edit = install(root, client, command) if installing else uninstall(root, client)
|
|
116
|
+
except McpConfigError as exc:
|
|
117
|
+
print(f"prodc: {exc}", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
if not args.dry_run:
|
|
120
|
+
apply(edit)
|
|
121
|
+
_report(edit.path.relative_to(root), edit.old_text, edit.new_text, args.dry_run, args.json)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_mcp_install(args: argparse.Namespace) -> int:
|
|
126
|
+
"""Add or update the ``prodc`` entry in the client's project config."""
|
|
127
|
+
return _run(args, installing=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_mcp_uninstall(args: argparse.Namespace) -> int:
|
|
131
|
+
"""Remove the ``prodc`` entry from the client's project config."""
|
|
132
|
+
return _run(args, installing=False)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Register the prodc MCP server in an MCP client's project config (``prodc mcp install``).
|
|
2
|
+
|
|
3
|
+
The write touches only the ``prodc`` entry, keeps every other server and key in order, and
|
|
4
|
+
refuses a file it cannot parse (for example JSON with comments) instead of overwriting it.
|
|
5
|
+
Codex (TOML) and user-scope configs are not written: prodc prints the client's own command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal, cast
|
|
15
|
+
|
|
16
|
+
ClientName = Literal["claude", "cursor", "vscode", "codex"]
|
|
17
|
+
|
|
18
|
+
DEFAULT_COMMAND = 'uvx --from "prodc[mcp]" prodc mcp serve'
|
|
19
|
+
"""The command written by default: runs the latest prodc[mcp] in its own isolated env."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ClientConfig:
|
|
24
|
+
"""Where one client keeps its project MCP config and how an entry looks there."""
|
|
25
|
+
|
|
26
|
+
path: str # relative to the project root
|
|
27
|
+
key: str # the top-level object that holds the servers
|
|
28
|
+
needs_type: bool # the client requires "type": "stdio"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CLIENTS: dict[str, ClientConfig] = {
|
|
32
|
+
"claude": ClientConfig(".mcp.json", "mcpServers", needs_type=False),
|
|
33
|
+
"cursor": ClientConfig(".cursor/mcp.json", "mcpServers", needs_type=True),
|
|
34
|
+
"vscode": ClientConfig(".vscode/mcp.json", "servers", needs_type=True),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class McpConfigError(Exception):
|
|
39
|
+
"""The config file exists but cannot be safely rewritten."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def entry(client: ClientName, command: str = DEFAULT_COMMAND) -> dict[str, Any]:
|
|
43
|
+
"""The server entry for ``client``: ``{command, args}`` plus ``type`` where required."""
|
|
44
|
+
cmd, *args = shlex.split(command)
|
|
45
|
+
out: dict[str, Any] = {}
|
|
46
|
+
if CLIENTS[client].needs_type:
|
|
47
|
+
out["type"] = "stdio"
|
|
48
|
+
out["command"] = cmd
|
|
49
|
+
out["args"] = args
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def manual_instructions(client: ClientName, scope: str, command: str = DEFAULT_COMMAND) -> str:
|
|
54
|
+
"""The client's own command for what prodc does not write (Codex, user scope)."""
|
|
55
|
+
if client == "codex":
|
|
56
|
+
cmd, *args = shlex.split(command)
|
|
57
|
+
toml_args = ", ".join(json.dumps(a) for a in args)
|
|
58
|
+
return (
|
|
59
|
+
f"codex mcp add prodc -- {command}\n\nor add to ~/.codex/config.toml:\n\n"
|
|
60
|
+
f'[mcp_servers.prodc]\ncommand = "{cmd}"\nargs = [{toml_args}]\n'
|
|
61
|
+
)
|
|
62
|
+
if client == "claude":
|
|
63
|
+
return f"claude mcp add --scope {scope} prodc -- {command}\n"
|
|
64
|
+
if client == "vscode":
|
|
65
|
+
return f"code --add-mcp '{json.dumps({'name': 'prodc', **entry(client, command)})}'\n"
|
|
66
|
+
return f"add to ~/.cursor/mcp.json under mcpServers:\n{json.dumps({'prodc': entry(client, command)}, indent=2)}\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ConfigEdit:
|
|
71
|
+
"""A proposed change to one client config file."""
|
|
72
|
+
|
|
73
|
+
path: Path
|
|
74
|
+
old_text: str # "" when the file does not exist
|
|
75
|
+
new_text: str # "" when the file should be deleted
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def changed(self) -> bool:
|
|
79
|
+
return self.old_text != self.new_text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load(path: Path) -> tuple[str, dict[str, Any]]:
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return "", {}
|
|
85
|
+
text = path.read_text(encoding="utf-8")
|
|
86
|
+
if not text.strip():
|
|
87
|
+
return text, {}
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(text)
|
|
90
|
+
except json.JSONDecodeError as exc:
|
|
91
|
+
raise McpConfigError(
|
|
92
|
+
f"{path} is not plain JSON ({exc.msg}, line {exc.lineno}); prodc will not rewrite it. "
|
|
93
|
+
"Add the entry by hand."
|
|
94
|
+
) from exc
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise McpConfigError(f"{path}: the top level is not a JSON object")
|
|
97
|
+
return text, cast(dict[str, Any], data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _dump(data: dict[str, Any]) -> str:
|
|
101
|
+
return json.dumps(data, indent=2) + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def install(root: Path, client: ClientName, command: str = DEFAULT_COMMAND) -> ConfigEdit:
|
|
105
|
+
"""Return the edit that adds or updates the ``prodc`` entry. Writes nothing."""
|
|
106
|
+
cc = CLIENTS[client]
|
|
107
|
+
path = root / cc.path
|
|
108
|
+
old, data = _load(path)
|
|
109
|
+
raw = data.get(cc.key, {})
|
|
110
|
+
if not isinstance(raw, dict):
|
|
111
|
+
raise McpConfigError(f"{path}: {cc.key!r} is not a JSON object")
|
|
112
|
+
servers = cast(dict[str, Any], raw)
|
|
113
|
+
new_servers = dict(servers)
|
|
114
|
+
new_servers["prodc"] = entry(client, command)
|
|
115
|
+
new = dict(data)
|
|
116
|
+
new[cc.key] = new_servers
|
|
117
|
+
return ConfigEdit(path, old, old if new == data else _dump(new))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def uninstall(root: Path, client: ClientName) -> ConfigEdit:
|
|
121
|
+
"""Return the edit that removes only the ``prodc`` entry. Writes nothing.
|
|
122
|
+
|
|
123
|
+
The file is deleted only when nothing but an empty servers object would be left.
|
|
124
|
+
"""
|
|
125
|
+
cc = CLIENTS[client]
|
|
126
|
+
path = root / cc.path
|
|
127
|
+
old, data = _load(path)
|
|
128
|
+
raw = data.get(cc.key)
|
|
129
|
+
if not isinstance(raw, dict) or "prodc" not in raw:
|
|
130
|
+
return ConfigEdit(path, old, old)
|
|
131
|
+
servers = cast(dict[str, Any], raw)
|
|
132
|
+
new_servers = {k: v for k, v in servers.items() if k != "prodc"}
|
|
133
|
+
new: dict[str, Any] = {**data, cc.key: new_servers}
|
|
134
|
+
if new == {cc.key: {}}:
|
|
135
|
+
return ConfigEdit(path, old, "")
|
|
136
|
+
return ConfigEdit(path, old, _dump(new))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply(edit: ConfigEdit) -> None:
|
|
140
|
+
"""Write (or delete) the file the edit describes."""
|
|
141
|
+
if not edit.changed:
|
|
142
|
+
return
|
|
143
|
+
if edit.new_text == "":
|
|
144
|
+
edit.path.unlink()
|
|
145
|
+
return
|
|
146
|
+
edit.path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
edit.path.write_text(edit.new_text, encoding="utf-8")
|