feat(post): comment_on_post tool + activity URNs in posts references#424
feat(post): comment_on_post tool + activity URNs in posts references#424Dominien wants to merge 2 commits into
Conversation
Closes the engagement loop the upstream MCP is missing: callers
can now chain get_person_profile(sections=posts) → comment_on_post
without a separate URL-discovery step.
comment_on_post tool
- Mirrors send_message's confirmation-gated write pattern. confirm_post
must be True for the write; False does a dry run that locates the
composer and returns confirmation_required.
- Composer detection is locale-independent: role + contenteditable +
aria-label hint with structural fallback. wait_for_selector lets the
Tiptap editor hydrate before the focus/type evaluate.
- Submit-button detection anchors on componentkey*="commentButtonSection"
(LinkedIn's React component identity, locale-independent), with
exact-match aria-label fallbacks for older renderings. Candidates are
re-ranked so componentkey matches always win — the broader aria-label
*= "Post" filter that earlier drafts used substring-matched the post
overflow's "Open control menu for post by NAME" and clicked the wrong
button.
- Click is a full mousedown → mouseup → click MouseEvent sequence so
React handlers fire reliably; bare btn.click() can register but not
trigger parent gates.
- Verify-after-action is two-stage: wait for composer to empty (the
reliable structural success signal — LinkedIn clears Tiptap only after
server-side accept), then confirm comment text is body-visible. The
composer-clear check eliminates the false positive where typed-but-
unsubmitted text matches body.innerText.includes(comment).
Activity URN harvest in posts section
- _SHARE_POST_PATH_RE in classify_link recognises /posts/<slug>-activity
-<id>-<sig>/ share permalinks alongside the existing /feed/update/
shape; both canonicalize to /feed/update/urn:li:activity:{id}/.
- _harvest_activity_urns reads [data-urn^=urn:li:activity:] /
[data-id^=urn:li:activity:] from post wrappers (the recent-activity
DOM renders timestamps as <button>, not <a>, so anchor-only references
miss the URN). Synthesises feed_post references with the canonical
permalink. Detection is structural — attribute presence + URN prefix,
no locale-dependent text per AGENTS.md scraping rules.
- Harvest runs only inside the existing is_activity branch in
_extract_page_once and is *prepended* to the references list so URNs
win the section reference cap over generic post-attachment anchors.
- dedupe_references collapses URN entries from the wrapper harvest with
any share-permalink anchors that classify_link picked up.
Tests
- 25 new tests, all green, ruff/format/ty/pre-commit clean (486 total).
TestPostTool, TestNormalizeActivityUrl, TestActivityUrnExtraction,
TestClassifyShareActivityPermalink. comment_on_post added to
TestToolTimeouts coverage.
Docs
- README tool table: comment_on_post row + posts-section URN note.
- docs/docker-hub.md features list: post engagement entry.
- manifest.json tools: comment_on_post entry.
## Synthetic prompt
> Add a comment_on_post MCP tool to linkedin-mcp-server v4.10.1 that
> mirrors send_message's confirmation-gated, JS-driven write pattern,
> with locale-independent composer + componentkey-anchored submit
> detection. Surface activity URNs in the get_person_profile(posts)
> section so callers can chain into comment_on_post — both via a
> /posts/<slug>-activity-<id>-<sig>/ regex in classify_link and a
> data-urn wrapper harvest inside the is_activity branch of
> _extract_page_once. Tests, docs, manifest.
Generated with Claude Opus 4.7 (1M context)
8e7173a to
626b044
Compare
Greptile SummaryThis PR adds a Confidence Score: 5/5Safe to merge — no P0 or P1 findings; all previous review concerns are addressed. Only P2-or-lower observations remain. The implementation correctly applies the ≥15-digit floor consistently across _normalize_activity_url and _SHARE_POST_PATH_RE, the locale-label concern from the prior review cycle is resolved with _COMMENT_SUBMIT_LABELS_BY_LOCALE, the test naming ambiguity is resolved by the updated docstring, and the overall pattern (exception handling, confirmation gate, two-stage verify) faithfully mirrors the send_message precedent. 486 tests pass including 25 new ones covering all new code paths. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller as MCP Caller
participant Tool as comment_on_post tool
participant Ext as LinkedInExtractor
participant Page as LinkedIn Page
Caller->>Tool: comment_on_post(post_url, text, confirm_post)
Tool->>Ext: post_comment(post_url, text, confirm_post=...)
Ext->>Ext: _normalize_activity_url(post_url)
Note over Ext: Accepts /feed/update/URN, /posts/slug-activity-id-sig/, bare URN, bare id
Ext->>Page: navigate to canonical /feed/update/ URL
Ext->>Page: wait_for_selector(main) + handle_modal_close
Ext->>Page: wait_for_selector(div[role=textbox][contenteditable])
Ext->>Page: evaluate() — focus first visible composer
alt composer not found
Ext-->>Tool: status: composer_unavailable
else confirm_post=False
Ext-->>Tool: status: confirmation_required
else confirm_post=True
Ext->>Page: keyboard.type(comment_text, delay=15)
Ext->>Page: evaluate() — click submit (componentkey → aria-label → Ctrl+Enter fallback)
Ext->>Page: _wait_for_composer_clear (poll until innerText empty)
Ext->>Page: _message_text_visible(comment_text)
alt composer cleared + text visible
Ext-->>Tool: status: posted, posted: true, comment_visible: true
else composer not cleared
Ext-->>Tool: status: post_unverified, posted: false
else text not visible
Ext-->>Tool: status: post_unverified, posted: true, comment_visible: false
end
end
Tool-->>Caller: result dict
Reviews (2): Last reviewed commit: "fix(post): address Greptile review on #4..." | Re-trigger Greptile |
- _normalize_activity_url: apply the same >=15-digit floor as
_SHARE_POST_PATH_RE to all three branches. Previously the
-activity-(\d+)- branch and the urn:li:activity:(\d+) branch had
no floor, so a URL like /posts/foo-activity-123-XYZ/ would silently
canonicalize to /feed/update/urn:li:activity:123/ instead of
returning None. Adds two regression tests
(test_short_id_in_share_permalink_rejected and
test_short_id_in_urn_rejected).
- comment_on_post submit selector: replace the inline aria-label
fallbacks with a module-level _COMMENT_SUBMIT_LABELS_BY_LOCALE table
per AGENTS.md scraping rules ("text-only signals must live behind
an explicit per-locale table"). Documents currently-supported
locales (en/de/fr) and known-unsupported ones (es/pt/nl/it/pl)
in-comment, with a one-line extension path. The Ctrl+Enter shortcut
remains the locale-agnostic last-resort fallback.
- test_harvest_helper_filters_invalid_urns_and_dedupes renamed to
test_harvest_helper_filters_invalid_urns. The helper itself does not
dedupe — the JS seen-set runs at the DOM level in the live evaluate,
and cross-source dedup happens downstream in dedupe_references.
Docstring + inline comment now say so explicitly.
488 tests pass (486 → 488), ruff/format/ty/pre-commit clean.
Summary
Adds the engagement-loop pair upstream is missing in v4.10.1: callers can chain
get_person_profile(sections=["posts"])→comment_on_postend-to-end without external URL discovery.comment_on_post— write tool mirroringsend_message's confirmation gate.confirm_post=Falsedoes a dry run that locates the composer;confirm_post=Truetypes and submits.postsreferences —classify_linknow recognises share-style/posts/<slug>-activity-<id>-<sig>/permalinks, and adata-urnharvest in theis_activitybranch picks up the URNs that the recent-activity DOM exposes only on post wrappers (timestamps render as<button>, not<a>, so anchor-only references missed them). Both surface asfeed_postreferences with the canonical/feed/update/urn:li:activity:NNN/URL.End-to-end live-tested against a German LinkedIn account:
get_person_profile(<user>, sections=posts)returned 12feed_postURNs;comment_on_post(<urn>, <text>, confirm_post=true)posted, composer cleared, comment visible in thread.Why bundled
comment_on_postis dead weight without a way to discover post URLs programmatically; the URN harvest is the missing other half. Happy to split into two PRs if preferred.Design highlights
Locale-independence (per
AGENTS.mdScraping Rules)[role=textbox][contenteditable=true]+ aria-label hint with structural fallback.componentkey*="commentButtonSection"(LinkedIn's React component identity, locale-independent), with exact-match aria-label fallbacks ("Comment"/"Kommentieren"/"Commenter") for older renderings.[data-urn^="urn:li:activity:"]+[data-id^="urn:li:activity:"]— attribute presence + URN prefix.^/posts/[^/?#]*-activity-(\d{15,})-[^/?#]+/?$.Reliability lessons baked in
wait_for_selectorlets it mount before the focus/type evaluate.mousedown → mouseup → clickMouseEventsequence; barebtn.click()registers but skips React parent gates that gate the actual submit.body.innerText.includes(comment).componentkeymatches always win — an earlier draft usedaria-label*="Post"and substring-matched"Open control menu for post by NAME", clicking the post overflow menu instead of the comment submit.Verification
uv run pytest --cov— 486 passing (461 baseline + 25 new). New classes:TestPostTool— happy path, dry-run, composer-unavailable, error.TestNormalizeActivityUrl— URL normalisation across all four input shapes.TestClassifyShareActivityPermalink— share-permalink regex + dedup with/feed/update/form.TestActivityUrnExtraction— wrapper harvest, non-activity-section skip, dedup with anchor-derived refs, helper-level filtering.comment_on_postadded toTestToolTimeouts.test_all_tools_have_global_timeout.uv run ruff check . && uv run ruff format --check . && uv run ty check && uv run pre-commit run --all-files— clean.Synthetic prompt
Generated with Claude Opus 4.7 (1M context)