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.
Files changed (118) hide show
  1. {prodc-0.7.4 → prodc-0.8.0}/CHANGELOG.md +11 -0
  2. {prodc-0.7.4 → prodc-0.8.0}/PKG-INFO +6 -1
  3. {prodc-0.7.4 → prodc-0.8.0}/README.md +3 -0
  4. {prodc-0.7.4 → prodc-0.8.0}/pyproject.toml +3 -1
  5. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/__init__.py +1 -1
  6. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/agent.md +7 -0
  7. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/app.py +11 -2
  8. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/common.py +8 -1
  9. prodc-0.8.0/src/prodc/cli/mcp.py +132 -0
  10. prodc-0.8.0/src/prodc/mcp_config.py +147 -0
  11. prodc-0.8.0/src/prodc/mcp_server.py +267 -0
  12. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/writer.py +10 -0
  13. prodc-0.8.0/tests/test_edit_sha.py +46 -0
  14. prodc-0.8.0/tests/test_mcp_install.py +115 -0
  15. prodc-0.8.0/tests/test_mcp_server.py +117 -0
  16. {prodc-0.7.4 → prodc-0.8.0}/.gitignore +0 -0
  17. {prodc-0.7.4 → prodc-0.8.0}/LICENSE +0 -0
  18. {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/features/orders.feature +0 -0
  19. {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/needs/jordan.yaml +0 -0
  20. {prodc-0.7.4 → prodc-0.8.0}/examples/quickstart/prodc.toml +0 -0
  21. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/adapters.py +0 -0
  22. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/anchor.py +0 -0
  23. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/board.py +0 -0
  24. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/__init__.py +0 -0
  26. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/model.py +0 -0
  27. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/ux.py +0 -0
  28. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/catalog/visual.py +0 -0
  29. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/__init__.py +0 -0
  30. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/__main__.py +0 -0
  31. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/commands.py +0 -0
  32. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/render.py +0 -0
  33. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/cli/serialize.py +0 -0
  34. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/config.py +0 -0
  35. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/dedup.py +0 -0
  36. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/glossary.py +0 -0
  37. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/guide.md +0 -0
  38. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/inbox.py +0 -0
  39. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/issues.py +0 -0
  40. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/loader.py +0 -0
  41. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/model.py +0 -0
  42. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/py.typed +0 -0
  43. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report.py +0 -0
  44. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report_export.py +0 -0
  45. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/report_html.py +0 -0
  46. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/status.py +0 -0
  47. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/storybook.py +0 -0
  48. {prodc-0.7.4 → prodc-0.8.0}/src/prodc/views.py +0 -0
  49. {prodc-0.7.4 → prodc-0.8.0}/tests/fixtures/storybook/index.json +0 -0
  50. {prodc-0.7.4 → prodc-0.8.0}/tests/test_adapters.py +0 -0
  51. {prodc-0.7.4 → prodc-0.8.0}/tests/test_anchor.py +0 -0
  52. {prodc-0.7.4 → prodc-0.8.0}/tests/test_answer.py +0 -0
  53. {prodc-0.7.4 → prodc-0.8.0}/tests/test_attest_policy.py +0 -0
  54. {prodc-0.7.4 → prodc-0.8.0}/tests/test_awaits.py +0 -0
  55. {prodc-0.7.4 → prodc-0.8.0}/tests/test_callbacks.py +0 -0
  56. {prodc-0.7.4 → prodc-0.8.0}/tests/test_capture_block_scalar.py +0 -0
  57. {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_add.py +0 -0
  58. {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_lint.py +0 -0
  59. {prodc-0.7.4 → prodc-0.8.0}/tests/test_catalog_registry.py +0 -0
  60. {prodc-0.7.4 → prodc-0.8.0}/tests/test_citation.py +0 -0
  61. {prodc-0.7.4 → prodc-0.8.0}/tests/test_claim_gating.py +0 -0
  62. {prodc-0.7.4 → prodc-0.8.0}/tests/test_cli.py +0 -0
  63. {prodc-0.7.4 → prodc-0.8.0}/tests/test_command_hook.py +0 -0
  64. {prodc-0.7.4 → prodc-0.8.0}/tests/test_confidence.py +0 -0
  65. {prodc-0.7.4 → prodc-0.8.0}/tests/test_config_keys.py +0 -0
  66. {prodc-0.7.4 → prodc-0.8.0}/tests/test_contradicts.py +0 -0
  67. {prodc-0.7.4 → prodc-0.8.0}/tests/test_decisions.py +0 -0
  68. {prodc-0.7.4 → prodc-0.8.0}/tests/test_declared_only.py +0 -0
  69. {prodc-0.7.4 → prodc-0.8.0}/tests/test_dedup.py +0 -0
  70. {prodc-0.7.4 → prodc-0.8.0}/tests/test_dedup_candidates.py +0 -0
  71. {prodc-0.7.4 → prodc-0.8.0}/tests/test_description.py +0 -0
  72. {prodc-0.7.4 → prodc-0.8.0}/tests/test_drafts.py +0 -0
  73. {prodc-0.7.4 → prodc-0.8.0}/tests/test_evalgate.py +0 -0
  74. {prodc-0.7.4 → prodc-0.8.0}/tests/test_expects.py +0 -0
  75. {prodc-0.7.4 → prodc-0.8.0}/tests/test_external_adapter.py +0 -0
  76. {prodc-0.7.4 → prodc-0.8.0}/tests/test_files_key.py +0 -0
  77. {prodc-0.7.4 → prodc-0.8.0}/tests/test_flowsteps.py +0 -0
  78. {prodc-0.7.4 → prodc-0.8.0}/tests/test_gitlab_source.py +0 -0
  79. {prodc-0.7.4 → prodc-0.8.0}/tests/test_glossary.py +0 -0
  80. {prodc-0.7.4 → prodc-0.8.0}/tests/test_importance.py +0 -0
  81. {prodc-0.7.4 → prodc-0.8.0}/tests/test_inbox.py +0 -0
  82. {prodc-0.7.4 → prodc-0.8.0}/tests/test_inventory.py +0 -0
  83. {prodc-0.7.4 → prodc-0.8.0}/tests/test_issues.py +0 -0
  84. {prodc-0.7.4 → prodc-0.8.0}/tests/test_lint_decision.py +0 -0
  85. {prodc-0.7.4 → prodc-0.8.0}/tests/test_loader.py +0 -0
  86. {prodc-0.7.4 → prodc-0.8.0}/tests/test_models.py +0 -0
  87. {prodc-0.7.4 → prodc-0.8.0}/tests/test_needref.py +0 -0
  88. {prodc-0.7.4 → prodc-0.8.0}/tests/test_path.py +0 -0
  89. {prodc-0.7.4 → prodc-0.8.0}/tests/test_path_views.py +0 -0
  90. {prodc-0.7.4 → prodc-0.8.0}/tests/test_playwright.py +0 -0
  91. {prodc-0.7.4 → prodc-0.8.0}/tests/test_principle_withdrawal.py +0 -0
  92. {prodc-0.7.4 → prodc-0.8.0}/tests/test_principles.py +0 -0
  93. {prodc-0.7.4 → prodc-0.8.0}/tests/test_relink.py +0 -0
  94. {prodc-0.7.4 → prodc-0.8.0}/tests/test_report.py +0 -0
  95. {prodc-0.7.4 → prodc-0.8.0}/tests/test_report_export.py +0 -0
  96. {prodc-0.7.4 → prodc-0.8.0}/tests/test_report_html.py +0 -0
  97. {prodc-0.7.4 → prodc-0.8.0}/tests/test_results_reports.py +0 -0
  98. {prodc-0.7.4 → prodc-0.8.0}/tests/test_review.py +0 -0
  99. {prodc-0.7.4 → prodc-0.8.0}/tests/test_roles.py +0 -0
  100. {prodc-0.7.4 → prodc-0.8.0}/tests/test_source_role.py +0 -0
  101. {prodc-0.7.4 → prodc-0.8.0}/tests/test_sources.py +0 -0
  102. {prodc-0.7.4 → prodc-0.8.0}/tests/test_stage.py +0 -0
  103. {prodc-0.7.4 → prodc-0.8.0}/tests/test_staleness.py +0 -0
  104. {prodc-0.7.4 → prodc-0.8.0}/tests/test_status.py +0 -0
  105. {prodc-0.7.4 → prodc-0.8.0}/tests/test_storybook.py +0 -0
  106. {prodc-0.7.4 → prodc-0.8.0}/tests/test_suggest.py +0 -0
  107. {prodc-0.7.4 → prodc-0.8.0}/tests/test_suggest_eval.py +0 -0
  108. {prodc-0.7.4 → prodc-0.8.0}/tests/test_ux_catalog.py +0 -0
  109. {prodc-0.7.4 → prodc-0.8.0}/tests/test_variants.py +0 -0
  110. {prodc-0.7.4 → prodc-0.8.0}/tests/test_view_links.py +0 -0
  111. {prodc-0.7.4 → prodc-0.8.0}/tests/test_views.py +0 -0
  112. {prodc-0.7.4 → prodc-0.8.0}/tests/test_views_report.py +0 -0
  113. {prodc-0.7.4 → prodc-0.8.0}/tests/test_visual_catalog.py +0 -0
  114. {prodc-0.7.4 → prodc-0.8.0}/tests/test_vitest.py +0 -0
  115. {prodc-0.7.4 → prodc-0.8.0}/tests/test_who_variants.py +0 -0
  116. {prodc-0.7.4 → prodc-0.8.0}/tests/test_withdrawn.py +0 -0
  117. {prodc-0.7.4 → prodc-0.8.0}/tests/test_writer.py +0 -0
  118. {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.7.4
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
@@ -1,3 +1,3 @@
1
1
  """prodc: product management as code."""
2
2
 
3
- __version__ = "0.7.4" # the single source of truth; pyproject reads it via [tool.hatch.version]
3
+ __version__ = "0.8.0" # the single source of truth; pyproject reads it via [tool.hatch.version]
@@ -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 main(argv: list[str] | None = None) -> int:
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
- args = parser.parse_args(argv)
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
- print(json.dumps({"file": edit.path.name, "dry_run": dry_run, "diff": edit.diff}, indent=2))
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")