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.
- profile_wrap_mcp-0.2.1/.gitignore +52 -0
- profile_wrap_mcp-0.2.1/PKG-INFO +68 -0
- profile_wrap_mcp-0.2.1/README.md +46 -0
- profile_wrap_mcp-0.2.1/glama.json +4 -0
- profile_wrap_mcp-0.2.1/profile_wrap_mcp/__init__.py +7 -0
- profile_wrap_mcp-0.2.1/profile_wrap_mcp/__main__.py +4 -0
- profile_wrap_mcp-0.2.1/profile_wrap_mcp/core.py +261 -0
- profile_wrap_mcp-0.2.1/profile_wrap_mcp/server.py +153 -0
- profile_wrap_mcp-0.2.1/pyproject.toml +41 -0
- profile_wrap_mcp-0.2.1/server.json +32 -0
- profile_wrap_mcp-0.2.1/smithery.yaml +15 -0
- profile_wrap_mcp-0.2.1/tests/conftest.py +134 -0
- profile_wrap_mcp-0.2.1/tests/data/server.schema.json +574 -0
- profile_wrap_mcp-0.2.1/tests/test_package.py +70 -0
- profile_wrap_mcp-0.2.1/tests/test_post_allowed.py +142 -0
- profile_wrap_mcp-0.2.1/tests/test_server.py +112 -0
|
@@ -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,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,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"
|