profile-wrap-mcp 0.2.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,52 @@
1
+ # Fonts and photos never enter the repository
2
+ *.woff
3
+ *.woff2
4
+ *.ttf
5
+ *.otf
6
+ *.eot
7
+ *.jpg
8
+ *.jpeg
9
+ *.heic
10
+ *.heif
11
+ *.tif
12
+ *.tiff
13
+ brand/fonts/
14
+ assets/fonts/
15
+ photos/
16
+ # The OFL demo fonts are the one exception: force-add them (git add -f) and pin their hash in release/leak-allow.json
17
+
18
+ # Python
19
+ __pycache__/
20
+ *.py[cod]
21
+ *.egg-info/
22
+ .pytest_cache/
23
+ .mypy_cache/
24
+ .ruff_cache/
25
+ .coverage
26
+ htmlcov/
27
+ build/
28
+ dist/
29
+ .venv/
30
+ venv/
31
+
32
+ # Zipapps are built in release, never committed
33
+ *.pyz
34
+
35
+ # Secrets
36
+ .env
37
+ .env.*
38
+ !.env.example
39
+ token.json
40
+ *.pem
41
+ *.key
42
+ *.p12
43
+ secrets/
44
+ leak-denylist*.json
45
+
46
+ # Node
47
+ node_modules/
48
+
49
+ # Local state
50
+ work/
51
+ .DS_Store
52
+ Thumbs.db
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: profile-wrap-mcp
3
+ Version: 0.2.1
4
+ Summary: MCP server (stdio) for Profile-Wrap: workspace status, drafts, gate, facts, review state, and posting of board-Allowed drafts only. Not affiliated with LinkedIn.
5
+ Project-URL: Homepage, https://github.com/NJ-Burrito/profile-wrap
6
+ Project-URL: Documentation, https://github.com/NJ-Burrito/profile-wrap/blob/main/docs/MCP.md
7
+ Project-URL: Issues, https://github.com/NJ-Burrito/profile-wrap/issues
8
+ Author: profile-wrap contributors
9
+ License: MIT
10
+ Keywords: bilingual,content,mcp,model-context-protocol,official-api,profile-wrap
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: mcp<3,>=2.3
20
+ Requires-Dist: profile-wrap<0.3,>=0.2.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # profile-wrap-mcp
24
+
25
+ <!-- mcp-name: io.github.NJ-Burrito/profile-wrap-mcp -->
26
+ mcp-name: io.github.NJ-Burrito/profile-wrap-mcp
27
+
28
+ A stdio [MCP](https://modelcontextprotocol.io) server for a [Profile-Wrap](https://github.com/NJ-Burrito/profile-wrap) workspace.
29
+ It lets an MCP client (Claude Code, Codex, any other) check the workspace, draft a post from a topic, run the
30
+ fact gate, manage unverified facts and read the review board. It can also post, but **only a draft a person
31
+ already pressed Allow on, on the review board**.
32
+
33
+ Independent, open source (MIT). **Not affiliated with LinkedIn.** The server talks to LinkedIn only through the
34
+ official API with your own developer app, and only inside `post_allowed`.
35
+
36
+ ## Tools
37
+
38
+ | Tool | What it does | Writes |
39
+ |---|---|---|
40
+ | `workspace_status` | posting mode, pauses and stops, 20 h gap, token state, draft counts, posts today | nothing |
41
+ | `calendar_list` | content-calendar entries, optional status filter | nothing |
42
+ | `draft_create` | new text draft from a topic with your chosen provider; verified facts only; state `draft` | `drafts/<slug>/` |
43
+ | `gate_run` | the deterministic gate on a draft (numbers, claims, language, banned patterns) | `gates.json` |
44
+ | `facts_list` | the fact register with each fact's status | nothing |
45
+ | `facts_add` | adds a fact as **unverified**; a person verifies it in a terminal | `research/facts.md` |
46
+ | `review_state` | review-board state of one draft or all, and whether it was Allowed | nothing |
47
+ | `post_allowed` | posts one board-Allowed draft; `confirm=false` (default) is a dry run | ledger, draft status |
48
+
49
+ Resources: `pw://calendar`, `pw://drafts`, `pw://drafts/{slug}`.
50
+
51
+ ## Safety
52
+
53
+ - `post_allowed` refuses every state except `allowed` (draft, revising, hold, rejected, needs_check, missed, posted, demo),
54
+ and refuses an `allowed` status that has no board Allow as the latest entry in the review history.
55
+ - It calls `profile_wrap.linkedin.post.publish`, the same function as `profile-wrap post --now`: gate PASS, 20 h gap,
56
+ one post a day, posting lock, 429/403 stop, pauses, and the ledger row written before anything is sent.
57
+ - No tool reads direct messages, sends connection invitations, comments, likes or verifies a fact.
58
+ - No key or token is ever returned. `draft_create` sends the topic and your verified facts to the provider you chose.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ uvx profile-wrap-mcp --workspace /path/to/your/workspace
64
+ claude mcp add profile-wrap -- uvx profile-wrap-mcp --workspace /path/to/your/workspace
65
+ ```
66
+
67
+ Without `--workspace` the server uses `PROFILE_WRAP_WORKSPACE`, then the usual Profile-Wrap lookup.
68
+ More: [docs/MCP.md](https://github.com/NJ-Burrito/profile-wrap/blob/main/docs/MCP.md).
@@ -0,0 +1,46 @@
1
+ # profile-wrap-mcp
2
+
3
+ <!-- mcp-name: io.github.NJ-Burrito/profile-wrap-mcp -->
4
+ mcp-name: io.github.NJ-Burrito/profile-wrap-mcp
5
+
6
+ A stdio [MCP](https://modelcontextprotocol.io) server for a [Profile-Wrap](https://github.com/NJ-Burrito/profile-wrap) workspace.
7
+ It lets an MCP client (Claude Code, Codex, any other) check the workspace, draft a post from a topic, run the
8
+ fact gate, manage unverified facts and read the review board. It can also post, but **only a draft a person
9
+ already pressed Allow on, on the review board**.
10
+
11
+ Independent, open source (MIT). **Not affiliated with LinkedIn.** The server talks to LinkedIn only through the
12
+ official API with your own developer app, and only inside `post_allowed`.
13
+
14
+ ## Tools
15
+
16
+ | Tool | What it does | Writes |
17
+ |---|---|---|
18
+ | `workspace_status` | posting mode, pauses and stops, 20 h gap, token state, draft counts, posts today | nothing |
19
+ | `calendar_list` | content-calendar entries, optional status filter | nothing |
20
+ | `draft_create` | new text draft from a topic with your chosen provider; verified facts only; state `draft` | `drafts/<slug>/` |
21
+ | `gate_run` | the deterministic gate on a draft (numbers, claims, language, banned patterns) | `gates.json` |
22
+ | `facts_list` | the fact register with each fact's status | nothing |
23
+ | `facts_add` | adds a fact as **unverified**; a person verifies it in a terminal | `research/facts.md` |
24
+ | `review_state` | review-board state of one draft or all, and whether it was Allowed | nothing |
25
+ | `post_allowed` | posts one board-Allowed draft; `confirm=false` (default) is a dry run | ledger, draft status |
26
+
27
+ Resources: `pw://calendar`, `pw://drafts`, `pw://drafts/{slug}`.
28
+
29
+ ## Safety
30
+
31
+ - `post_allowed` refuses every state except `allowed` (draft, revising, hold, rejected, needs_check, missed, posted, demo),
32
+ and refuses an `allowed` status that has no board Allow as the latest entry in the review history.
33
+ - It calls `profile_wrap.linkedin.post.publish`, the same function as `profile-wrap post --now`: gate PASS, 20 h gap,
34
+ one post a day, posting lock, 429/403 stop, pauses, and the ledger row written before anything is sent.
35
+ - No tool reads direct messages, sends connection invitations, comments, likes or verifies a fact.
36
+ - No key or token is ever returned. `draft_create` sends the topic and your verified facts to the provider you chose.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uvx profile-wrap-mcp --workspace /path/to/your/workspace
42
+ claude mcp add profile-wrap -- uvx profile-wrap-mcp --workspace /path/to/your/workspace
43
+ ```
44
+
45
+ Without `--workspace` the server uses `PROFILE_WRAP_WORKSPACE`, then the usual Profile-Wrap lookup.
46
+ More: [docs/MCP.md](https://github.com/NJ-Burrito/profile-wrap/blob/main/docs/MCP.md).
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": ["NJ-Burrito"]
4
+ }
@@ -0,0 +1,7 @@
1
+ """profile-wrap-mcp: a stdio MCP server over a Profile-Wrap workspace.
2
+
3
+ Independent open-source tool, not affiliated with LinkedIn. It reads and drafts; the only tool that
4
+ can reach LinkedIn is ``post_allowed``, and it refuses every draft a person has not Allowed on the
5
+ review board.
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,261 @@
1
+ """The tool logic, free of any MCP types, so it can be tested directly and through a client.
2
+
3
+ Nothing here reads DMs or sends invitations. The one outbound path to LinkedIn is
4
+ ``profile_wrap.linkedin.post.publish`` (called from ``post_allowed``); there is no second posting path.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import datetime as _dt
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional
14
+
15
+ from profile_wrap import config as config_mod
16
+ from profile_wrap import ledger, limits
17
+ from profile_wrap.atomic import atomic_write_json, atomic_write_text
18
+ from profile_wrap.board.security import SLUG_RE
19
+ from profile_wrap.board.store import BoardStore
20
+ from profile_wrap.facts import registers as reg
21
+ from profile_wrap.fsutil import is_inside
22
+ from profile_wrap.gate.claims import facts_path
23
+ from profile_wrap.linkedin import auth, post
24
+ from profile_wrap.plan import calendar as cal_mod
25
+ from profile_wrap.providers import ProviderError, get_provider
26
+ from profile_wrap.providers import settings as prov_settings
27
+
28
+ MAX_POSTS_PER_DAY = 1
29
+ MAX_TOPIC = 400
30
+ MAX_CAPTION = 3000
31
+
32
+ # Review-board state names shown to the model. "allowed" is the board's "queued" (set only by a
33
+ # person pressing Allow, after a gate PASS).
34
+ STATE_NAMES = {"queued": "allowed", "draft": "draft", "revising": "revising", "hold": "hold",
35
+ "missed": "missed", "needs_check": "needs_check", "posted": "posted",
36
+ "rejected": "rejected", "demo": "demo"}
37
+
38
+
39
+ @dataclass
40
+ class Context:
41
+ ws: Path
42
+ gate_fn: Optional[Callable[..., Dict[str, Any]]] = None # default: the real gate
43
+ client: Any = None # default: a real LinkedIn client
44
+ provider_factory: Callable[..., Any] = get_provider
45
+ now: Optional[Callable[[], _dt.datetime]] = None
46
+ extra: Dict[str, Any] = field(default_factory=dict)
47
+
48
+ def cfg(self) -> Dict[str, Any]:
49
+ return config_mod.load(self.ws)
50
+
51
+ def gate(self, slug: str, write: bool = True) -> Dict[str, Any]:
52
+ if self.gate_fn is not None:
53
+ return self.gate_fn(slug, self.ws, write)
54
+ return post.default_gate(slug, self.ws, write)
55
+
56
+
57
+ class ToolRefused(Exception):
58
+ """A request was refused on purpose. The message is safe to show the model."""
59
+
60
+
61
+ def _slug(slug: str) -> str:
62
+ if not isinstance(slug, str) or not SLUG_RE.match(slug):
63
+ raise ToolRefused("slug must be lowercase letters, digits and hyphens (1-100 characters)")
64
+ return slug
65
+
66
+
67
+ def _draft_dir(ctx: Context, slug: str) -> Path:
68
+ d = ctx.ws / "drafts" / _slug(slug)
69
+ if not is_inside(d, ctx.ws) or not (d / "meta.json").is_file():
70
+ raise ToolRefused(f"no draft '{slug}'")
71
+ return d
72
+
73
+
74
+ def _meta(d: Path) -> Dict[str, Any]:
75
+ try:
76
+ data = json.loads((d / "meta.json").read_text(encoding="utf-8-sig"))
77
+ except (OSError, ValueError):
78
+ return {"status": "unreadable"}
79
+ return data if isinstance(data, dict) else {"status": "unreadable"}
80
+
81
+
82
+ def _now(ctx: Context) -> Optional[_dt.datetime]:
83
+ return ctx.now() if ctx.now else None
84
+
85
+
86
+ # ------------------------------------------------------------------ workspace_status
87
+
88
+ def workspace_status(ctx: Context) -> Dict[str, Any]:
89
+ cfg = ctx.cfg()
90
+ modes = config_mod.effective_modes(cfg, ctx.ws)
91
+ counts: Dict[str, int] = {}
92
+ for p in sorted((ctx.ws / "drafts").glob("*/meta.json")):
93
+ st = STATE_NAMES.get(str(_meta(p.parent).get("status")), "unreadable")
94
+ counts[st] = counts.get(st, 0) + 1
95
+ last = ledger.last_activity(ctx.ws)
96
+ tok = auth.token_status(auth.secrets_dir(cfg), int(cfg["linkedin"]["token_warn_days"]))
97
+ return {"workspace": str(ctx.ws), "posting_mode": modes["posting"], "paused": limits.pause_flags(ctx.ws),
98
+ "stop": post.stop_active(ctx.ws), "gap_problem": post.gap_problem(ctx.ws, cfg, _now(ctx)),
99
+ "token": {"state": tok["state"], "days_left": tok["days_left"]},
100
+ "drafts_by_state": counts, "ledger_rows": len(ledger.entries(ctx.ws)),
101
+ "last_post_activity": last.isoformat() if last else None,
102
+ "posts_today": ledger.posts_today(ctx.ws), "max_posts_per_day": MAX_POSTS_PER_DAY}
103
+
104
+
105
+ # ------------------------------------------------------------------ calendar
106
+
107
+ def calendar_list(ctx: Context, status: Optional[str] = None, limit: int = 60) -> Dict[str, Any]:
108
+ try:
109
+ entries = cal_mod.load(ctx.ws)["entries"]
110
+ except cal_mod.CalendarError as exc:
111
+ raise ToolRefused(str(exc)) from exc
112
+ if status:
113
+ entries = [e for e in entries if e.get("status") == status]
114
+ limit = max(1, min(int(limit), 365))
115
+ return {"count": len(entries), "entries": entries[:limit]}
116
+
117
+
118
+ # ------------------------------------------------------------------ facts
119
+
120
+ def facts_list(ctx: Context, status: Optional[str] = None) -> Dict[str, Any]:
121
+ register = reg.load_register(facts_path(ctx.ws, ctx.cfg()), "fact")
122
+ rows = [{"id": e.id, "claim": e.text, "numbers": e.numbers_raw, "source": e.source,
123
+ "status": e.status} for e in register.entries if not status or e.status == status]
124
+ return {"count": len(rows), "facts": rows, "problems": register.problems}
125
+
126
+
127
+ def facts_add(ctx: Context, claim: str, source: str, numbers: str = "") -> Dict[str, Any]:
128
+ """Adds an UNVERIFIED fact (status draft). Verification is a human step in a terminal."""
129
+ try:
130
+ e = reg.add_entry(facts_path(ctx.ws, ctx.cfg()), "fact", text=claim, numbers=numbers, source=source)
131
+ except ValueError as exc:
132
+ raise ToolRefused(str(exc)) from exc
133
+ return {"id": e.id, "status": e.status,
134
+ "next": f"A person verifies it: profile-wrap facts verify {e.id} --evidence <path|url>. "
135
+ "Drafts cannot cite it until then."}
136
+
137
+
138
+ # ------------------------------------------------------------------ draft_create
139
+
140
+ def _slugify(text: str, today: _dt.date) -> str:
141
+ base = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60].strip("-") or "draft"
142
+ return f"{base}-{today.isoformat()}"
143
+
144
+
145
+ def draft_create(ctx: Context, topic: str, lang: str = "en", slug: Optional[str] = None) -> Dict[str, Any]:
146
+ topic = (topic or "").strip()
147
+ if not topic or len(topic) > MAX_TOPIC:
148
+ raise ToolRefused(f"topic is required and must be at most {MAX_TOPIC} characters")
149
+ cfg = ctx.cfg()
150
+ today = (ctx.now() if ctx.now else _dt.datetime.now(_dt.timezone.utc)).date()
151
+ slug = _slug(slug) if slug else _slugify(topic, today)
152
+ if not SLUG_RE.match(slug):
153
+ slug = f"draft-{today.isoformat()}"
154
+ folder = ctx.ws / "drafts" / slug
155
+ if folder.exists():
156
+ raise ToolRefused(f"draft '{slug}' already exists; nothing was overwritten")
157
+ chosen = prov_settings.load(ctx.ws)
158
+ if not chosen.get("provider"):
159
+ raise ToolRefused("no provider is chosen yet: run 'profile-wrap providers use <id>'")
160
+ verified = [e for e in reg.load_register(facts_path(ctx.ws, cfg), "fact").entries
161
+ if e.status == "verified"]
162
+ facts_txt = "\n".join(f"- {e.id}: {e.text} ({e.numbers_raw})" for e in verified) or "(none)"
163
+ persona = cfg.get("persona", {})
164
+ system = ("You write one LinkedIn post caption. Plain text, short lines, no em dashes. "
165
+ "Use ONLY the verified facts below for any claim or number about the author; never invent "
166
+ "numbers, results or stories. Reply with the caption text only.\n"
167
+ f"Language: {lang}. Author: {persona.get('name') or 'the author'}, {persona.get('role') or ''}.\n"
168
+ f"Verified facts:\n{facts_txt}")
169
+ try:
170
+ provider = ctx.provider_factory(chosen["provider"], chosen.get("model"), chosen.get("base_url"))
171
+ result = provider.complete(system, [{"role": "user", "content": f"Topic: {topic}"}], max_tokens=700)
172
+ except ProviderError as exc:
173
+ raise ToolRefused(f"provider failed ({exc.kind}): {exc}") from exc
174
+ caption = (result.text or "").strip()
175
+ if not caption:
176
+ raise ToolRefused("the provider returned no text; nothing was written")
177
+ caption = caption[:MAX_CAPTION]
178
+ pillars = persona.get("pillars") or [{"id": "P1"}]
179
+ meta = {"schema_version": 1, "slug": slug, "title": topic[:120], "lang": lang, "format": "text",
180
+ "pillar": str(pillars[0].get("id", "P1")), "status": "draft", "claims": [],
181
+ "created_by": "profile-wrap-mcp", "provider": result.provider, "model": result.model}
182
+ folder.mkdir(parents=True)
183
+ atomic_write_text(folder / "caption.txt", caption + "\n")
184
+ atomic_write_json(folder / "meta.json", meta)
185
+ gate = ctx.gate(slug, False)
186
+ return {"slug": slug, "status": "draft", "provider": result.provider, "model": result.model,
187
+ "caption": caption, "gate_preview": gate.get("summary"),
188
+ "next": "A person reviews it and presses Allow on the board. Nothing was posted."}
189
+
190
+
191
+ # ------------------------------------------------------------------ gate_run
192
+
193
+ def gate_run(ctx: Context, slug: str) -> Dict[str, Any]:
194
+ _draft_dir(ctx, slug)
195
+ res = ctx.gate(slug, True)
196
+ return {"slug": slug, "summary": res.get("summary"), "fails": res.get("fails", []),
197
+ "warnings": res.get("warnings", [])}
198
+
199
+
200
+ # ------------------------------------------------------------------ review_state
201
+
202
+ def _allowed_by_board(ctx: Context, d: Path, meta: Dict[str, Any]) -> Optional[str]:
203
+ """None when a person Allowed this draft on the board; otherwise the reason it did not happen."""
204
+ if meta.get("status") != "queued":
205
+ return f"state is '{STATE_NAMES.get(str(meta.get('status')), meta.get('status'))}', not 'allowed'"
206
+ hist = BoardStore(ctx.ws).history(d)
207
+ last = hist[-1] if hist else None
208
+ if not last or last.get("action") != "allow" or last.get("to") != "queued":
209
+ return "the review history has no board Allow as its latest decision"
210
+ return None
211
+
212
+
213
+ def review_state(ctx: Context, slug: Optional[str] = None) -> Dict[str, Any]:
214
+ dirs = [_draft_dir(ctx, slug)] if slug else sorted(
215
+ p.parent for p in (ctx.ws / "drafts").glob("*/meta.json") if SLUG_RE.match(p.parent.name))
216
+ out = []
217
+ store = BoardStore(ctx.ws)
218
+ for d in dirs:
219
+ meta = _meta(d)
220
+ hist = store.history(d)
221
+ why = _allowed_by_board(ctx, d, meta)
222
+ out.append({"slug": d.name, "state": STATE_NAMES.get(str(meta.get("status")), "unreadable"),
223
+ "allowed_on_board": why is None, "not_allowed_because": why,
224
+ "last_decision": hist[-1] if hist else None,
225
+ "scheduled_at": meta.get("scheduled_at")})
226
+ return {"count": len(out), "drafts": out}
227
+
228
+
229
+ # ------------------------------------------------------------------ post_allowed
230
+
231
+ def post_allowed(ctx: Context, slug: str, confirm: bool = False) -> Dict[str, Any]:
232
+ """Post one draft a person already Allowed. confirm=false returns the dry-run plan only."""
233
+ d = _draft_dir(ctx, slug)
234
+ why = _allowed_by_board(ctx, d, _meta(d))
235
+ if why:
236
+ return {"status": "refused", "kind": "not_allowed", "slug": slug,
237
+ "reason": f"only drafts Allowed on the review board can be posted: {why}"}
238
+ cfg = ctx.cfg()
239
+ if ledger.posts_today(ctx.ws) >= MAX_POSTS_PER_DAY:
240
+ return {"status": "refused", "kind": "daily_cap", "slug": slug,
241
+ "reason": f"daily cap reached: at most {MAX_POSTS_PER_DAY} post a day"}
242
+ if not confirm:
243
+ plan = post.plan(ctx.ws, slug, cfg, gate_fn=ctx.gate_fn)
244
+ return {"status": "dry", "slug": slug, "plan": plan,
245
+ "reason": "nothing was sent. Call again with confirm=true to post."}
246
+ out = post.publish(ctx.ws, slug, cfg, by="mcp", client=ctx.client, gate_fn=ctx.gate_fn,
247
+ now=_now(ctx), require_status="queued")
248
+ return out.as_dict()
249
+
250
+
251
+ # ------------------------------------------------------------------ resources
252
+
253
+ def read_draft(ctx: Context, slug: str) -> Dict[str, Any]:
254
+ d = _draft_dir(ctx, slug)
255
+ cap = d / "caption.txt"
256
+ return {"slug": slug, "meta": _meta(d),
257
+ "caption": cap.read_text(encoding="utf-8-sig") if cap.is_file() else ""}
258
+
259
+
260
+ def list_drafts(ctx: Context) -> Dict[str, Any]:
261
+ return review_state(ctx)
@@ -0,0 +1,153 @@
1
+ """The MCP server: tool and resource wiring over ``core``. stdio transport only."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import functools
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp_types import ToolAnnotations
14
+
15
+ from . import __version__, core
16
+ from .core import Context
17
+
18
+ INSTRUCTIONS = (
19
+ "Profile-Wrap workspace tools. Independent open-source tool, not affiliated with LinkedIn. "
20
+ "Everything is read-only or draft-only except post_allowed, which posts a draft ONLY when a person "
21
+ "has already pressed Allow on the review board. No tool reads direct messages or sends "
22
+ "connection invitations. Facts you add are unverified until a person verifies them."
23
+ )
24
+
25
+ READ = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
26
+ WRITE_LOCAL = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
27
+ POSTS = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True)
28
+
29
+
30
+ def _explained(fn):
31
+ """A deliberate refusal becomes a readable result; the SDK would otherwise mask it as a bare error."""
32
+ @functools.wraps(fn)
33
+ def run(*a, **k):
34
+ try:
35
+ return fn(*a, **k)
36
+ except core.ToolRefused as exc:
37
+ return {"status": "refused", "reason": str(exc)}
38
+ return run
39
+
40
+
41
+ def build_server(ctx: Context) -> MCPServer:
42
+ app = MCPServer("profile-wrap", instructions=INSTRUCTIONS, version=__version__)
43
+ tool = app.tool
44
+
45
+ @tool(annotations=READ)
46
+ @_explained
47
+ def workspace_status() -> Dict[str, Any]:
48
+ """Summarise the Profile-Wrap workspace: posting mode, pauses and stops, the 20 h gap guard,
49
+ token state (never the token), draft counts by review state, and how many posts went out today.
50
+ Read-only; makes no network call."""
51
+ return core.workspace_status(ctx)
52
+
53
+ @tool(annotations=READ)
54
+ @_explained
55
+ def calendar_list(status: Optional[str] = None, limit: int = 60) -> Dict[str, Any]:
56
+ """List content-calendar entries (planned, built, posted, skipped, blocked), optionally
57
+ filtered by status. Read-only."""
58
+ return core.calendar_list(ctx, status, limit)
59
+
60
+ @tool(annotations=WRITE_LOCAL)
61
+ @_explained
62
+ def draft_create(topic: str, lang: str = "en", slug: Optional[str] = None) -> Dict[str, Any]:
63
+ """Create a new text draft from a topic using the provider chosen with 'profile-wrap providers use'.
64
+ The model sees only VERIFIED facts and is told not to invent numbers. The draft starts in state
65
+ 'draft'; it never overwrites an existing draft, is never queued, and nothing is posted. The topic
66
+ is sent to the chosen provider (your own key, or your local claude CLI)."""
67
+ return core.draft_create(ctx, topic, lang, slug)
68
+
69
+ @tool(annotations=WRITE_LOCAL)
70
+ @_explained
71
+ def gate_run(slug: str) -> Dict[str, Any]:
72
+ """Run the deterministic gate (numbers, claims, language, banned patterns) on drafts/<slug> and
73
+ record gates.json. Returns PASS or FAIL with the failing lines. Local only; no model, no network."""
74
+ return core.gate_run(ctx, slug)
75
+
76
+ @tool(annotations=READ)
77
+ @_explained
78
+ def facts_list(status: Optional[str] = None) -> Dict[str, Any]:
79
+ """List the fact register (id, claim, numbers, source, status: draft or verified). Only
80
+ verified facts may back a number in a post. Read-only."""
81
+ return core.facts_list(ctx, status)
82
+
83
+ @tool(annotations=WRITE_LOCAL)
84
+ @_explained
85
+ def facts_add(claim: str, source: str, numbers: str = "") -> Dict[str, Any]:
86
+ """Add one fact as UNVERIFIED (status draft). It cannot back a post until a person verifies it
87
+ in a terminal ('profile-wrap facts verify'). This server has no verify tool, by design."""
88
+ return core.facts_add(ctx, claim, source, numbers)
89
+
90
+ @tool(annotations=READ)
91
+ @_explained
92
+ def review_state(slug: Optional[str] = None) -> Dict[str, Any]:
93
+ """Show the review-board state of one draft (or all): draft, revising, hold, allowed, rejected,
94
+ needs_check, missed, posted, demo; whether a person Allowed it on the board; the last board
95
+ decision. 'allowed' is the only state post_allowed accepts. Read-only."""
96
+ return core.review_state(ctx, slug)
97
+
98
+ @tool(annotations=POSTS)
99
+ @_explained
100
+ def post_allowed(slug: str, confirm: bool = False) -> Dict[str, Any]:
101
+ """Post ONE draft to LinkedIn through the official API, but only if a person already pressed Allow
102
+ on the review board. Refuses draft, revising, hold, rejected, needs_check, missed, posted and demo
103
+ drafts, and refuses an 'allowed' status that lacks a board Allow in its review history. Also
104
+ enforces the gate PASS, the 20 hour gap between posts, the 1 post a day cap, the posting lock, a
105
+ 429/403 stop, pauses, and writes the ledger row before anything is sent. With confirm=false (the
106
+ default) it returns the dry-run plan and sends nothing. This is the only tool that can reach LinkedIn."""
107
+ return core.post_allowed(ctx, slug, confirm)
108
+
109
+ @app.resource("pw://calendar", mime_type="application/json")
110
+ def calendar_resource() -> str:
111
+ """The content calendar (strategy/calendar.json)."""
112
+ return json.dumps(core.calendar_list(ctx, None, 365), ensure_ascii=False, indent=2)
113
+
114
+ @app.resource("pw://drafts", mime_type="application/json")
115
+ def drafts_resource() -> str:
116
+ """Every draft with its review-board state."""
117
+ return json.dumps(core.list_drafts(ctx), ensure_ascii=False, indent=2)
118
+
119
+ @app.resource("pw://drafts/{slug}", mime_type="application/json")
120
+ def draft_resource(slug: str) -> str:
121
+ """One draft: meta.json and caption.txt."""
122
+ return json.dumps(core.read_draft(ctx, slug), ensure_ascii=False, indent=2)
123
+
124
+ return app
125
+
126
+
127
+ def resolve_workspace(arg: Optional[str]) -> Path:
128
+ if arg:
129
+ return Path(arg).expanduser().resolve()
130
+ from profile_wrap.gate.config import resolve_workspace as _resolve
131
+ return _resolve(None)
132
+
133
+
134
+ def main(argv: Optional[List[str]] = None) -> int:
135
+ p = argparse.ArgumentParser(prog="profile-wrap-mcp",
136
+ description="stdio MCP server over a Profile-Wrap workspace (not affiliated with LinkedIn)")
137
+ p.add_argument("--workspace", help="workspace folder (default: PROFILE_WRAP_WORKSPACE, else the usual lookup)")
138
+ p.add_argument("--version", action="store_true")
139
+ args = p.parse_args(argv)
140
+ if args.version:
141
+ print(__version__)
142
+ return 0
143
+ ws = resolve_workspace(args.workspace)
144
+ if not ws.is_dir():
145
+ print(f"profile-wrap-mcp: workspace not found: {ws}", file=sys.stderr)
146
+ return 2
147
+ os.environ.setdefault("PYTHONUTF8", "1")
148
+ build_server(Context(ws=ws)).run("stdio")
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "profile-wrap-mcp"
7
+ version = "0.2.1"
8
+ description = "MCP server (stdio) for Profile-Wrap: workspace status, drafts, gate, facts, review state, and posting of board-Allowed drafts only. Not affiliated with LinkedIn."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "profile-wrap contributors" }]
13
+ keywords = ["mcp", "model-context-protocol", "profile-wrap", "content", "official-api", "bilingual"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3",
22
+ ]
23
+ dependencies = [
24
+ "profile_wrap>=0.2.0,<0.3",
25
+ "mcp>=2.3,<3",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/NJ-Burrito/profile-wrap"
30
+ Documentation = "https://github.com/NJ-Burrito/profile-wrap/blob/main/docs/MCP.md"
31
+ Issues = "https://github.com/NJ-Burrito/profile-wrap/issues"
32
+
33
+ [project.scripts]
34
+ profile-wrap-mcp = "profile_wrap_mcp.server:main"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["profile_wrap_mcp"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = "-q"