Skip to content

feat(network): add get_pending_invitations tool#447

Open
ghul0 wants to merge 6 commits into
stickerdaniel:mainfrom
ghul0:feature/446-get-pending-invitations
Open

feat(network): add get_pending_invitations tool#447
ghul0 wants to merge 6 commits into
stickerdaniel:mainfrom
ghul0:feature/446-get-pending-invitations

Conversation

@ghul0

@ghul0 ghul0 commented May 15, 2026

Copy link
Copy Markdown

Summary

Adds a new read-only MCP tool get_pending_invitations for listing pending LinkedIn network invitations (received or sent). This fills the gap noted in #446 — recruiter "Invite + note" messages live in /mynetwork/invitation-manager/ and never reach get_inbox until accepted, so an agent assisting with inbound triage cannot see them today.

What's in scope

  • New tool exposed via tools/network.py::register_network_tools with signature get_pending_invitations(ctx, limit=20, kind="received"|"sent").
  • New extractor method LinkedInExtractor.get_pending_invitations(limit, kind) that follows the get_inbox template line-for-line: _navigate_to_page -> detect_rate_limit -> _wait_for_main_text -> handle_modal_close -> _scroll_main_scrollable_region -> _expand_invitation_note_toggles -> _extract_root_content(["main"]) -> strip_linkedin_noise -> build_references -> _single_section_result.
  • Auto-expansion of truncated invitation notes via the new _expand_invitation_note_toggles helper. Uses the locale-independent data-testid="expandable-text-button" selector, injects a scoped one-shot stylesheet to re-enable pointer-events (LinkedIn ships these buttons with inline pointer-events: none), and dispatches a synthetic bubbling MouseEvent so the React handler fires. A button is tagged with data-mcp-clicked only after a successful dispatch, so a click that throws is left unmarked and retried on the second pass; the selector also skips aria-expanded="true" so an already-expanded note is never toggled closed.
  • Empty-state handling for kind="received": when the selected ALL-count tab reads zero, the method returns an empty single-section result instead of scraping the unrelated "people you may know" recommendation cards LinkedIn renders below the empty invitations list. The zero check is locale-independent — it locates the selected tab via aria-current="true" + the stable /invitation-manager/received/ALL URL, and only the digit count is read as text (guarded behind that structural selector, documented in the helper docstring).
  • Per-call limit bounds the returned references (build_references(...)[:limit]), and _REFERENCE_CAPS["invitations"] is set to 100 to match the tool's Field(le=100) ceiling so larger invitation lists are not silently truncated at the default cap of 12.
  • Registration in server.py::create_mcp_server().
  • Tests: TestNetworkTools in tests/test_tools.py (happy path, sent kind, invalid kind rejection, limit-bound rejection; _make_mock_extractor extended; tool added to both timeout-coverage tuples) plus TestGetPendingInvitations in tests/test_scraping.py (extractor-level regression coverage: [:limit] reference trimming, the sent navigation target, the aria-expanded="true" / data-mcp-clicked skip guards, and the zero-count empty-state path).
  • README tool table, docs/docker-hub.md features list, and manifest.json tools array all updated.

Intentionally out of scope

  • Accept / ignore / withdraw actions are deferred to a follow-up PR if this lands. Keeping the surface area read-only here both shrinks the diff and avoids exposing destructive operations on a surface that hasn't been exercised yet.
  • No new entry in fields.py — this is a one-page tool, not a sectioned scraper, matching get_feed / get_inbox.

Testing

  • uv run pytest: 551 passed locally. One pre-existing failure in test_browser_security.py::test_harden_linkedin_tree_noop_outside_linkedin reproduces on main before this branch with umask=002; it's environmental and unrelated.
  • uv run ruff check .: clean. uv run ty check: clean. uv run pre-commit run --all-files: all hooks pass.
  • Live verified end-to-end via get_pending_invitations (read-only) on a real account in two states:
    • non-empty kind="received": result shape conforms to {url, sections, references}; the section text contained inviter blocks with every truncated invitation note auto-expanded (text matched the collapse verb N times, the expand verb 0 times); references carried kind: "person" entries with /in/USERNAME/ URLs, sliced to limit.
    • zero kind="received": returns sections: {} with no references, confirming the "people you may know" recommendation cards are not surfaced as invitations.

Synthetic prompt

Add a read-only get_pending_invitations MCP tool that lists pending LinkedIn network invitations (received or sent) by navigating to /mynetwork/invitation-manager/{kind}/, mirroring the existing get_inbox extractor pattern. Before extracting, auto-expand truncated invitation notes by dispatching a synthetic click on each data-testid="expandable-text-button" (inject a scoped pointer-events stylesheet override, mark each button data-mcp-clicked only after a successful dispatch so failed clicks retry, and skip aria-expanded="true" so expanded notes are never re-collapsed). Bound returned references to the per-call limit and add an invitations entry to _REFERENCE_CAPS. For kind="received", when the selected ALL-count tab reads zero, return an empty result instead of scraping the "people you may know" recommendations, locating the tab via the locale-independent aria-current="true" + /received/ALL URL signal. Wire it through tools/network.py and server.py::create_mcp_server(), add TestNetworkTools in tests/test_tools.py and TestGetPendingInvitations in tests/test_scraping.py, and update README, docs/docker-hub.md, and manifest.json. Accept/ignore/withdraw actions are intentionally out of scope.

Generated with Claude Opus 4.7 and GPT-5.5

Closes #446

@ghul0 ghul0 marked this pull request as ready for review May 16, 2026 18:53
@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new read-only get_pending_invitations MCP tool that navigates to /mynetwork/invitation-manager/{received|sent}/, scrolls to pre-load invitation cards, auto-expands truncated invitation notes via synthetic click dispatch, and returns the standard {url, sections, references} payload. All three previously-raised review items have been resolved: the invitations reference cap is now set to 100 in _REFERENCE_CAPS, the expand selector guards against re-collapsing already-expanded notes with :not([aria-expanded="true"]), and references are sliced to the per-call limit before returning.

  • New extractor method (get_pending_invitations) follows the get_inbox template; guarded by _received_invitation_count_is_zero to avoid scraping "people you may know" recommendation cards on an empty received-invitations page.
  • New tool file (tools/network.py) with Field(ge=1, le=100) validation and consistent auth-error handling; registered in server.py.
  • Tests cover reference trimming, sent-URL routing, zero-count early exit, and the expand-selector correctness guarantees; both timeout-coverage tuples updated.

Confidence Score: 5/5

Safe to merge; the change is additive and read-only, all three previously raised issues have been resolved, and the implementation mirrors the established get_inbox pattern faithfully.

All prior feedback has been addressed — the invitations reference cap is correctly set, the expand selector now guards against re-collapsing notes, and references are trimmed to the per-call limit. The remaining notes are minor ordering and iteration-count trade-offs that do not affect correctness.

No files require special attention; extractor.py has a minor ordering opportunity around the zero-count guard, but nothing that affects correctness.

Important Files Changed

Filename Overview
linkedin_mcp_server/scraping/extractor.py Adds get_pending_invitations, _received_invitation_count_is_zero, and _expand_invitation_note_toggles; all three previous review items addressed (references cap, aria-expanded guard, limit trim)
linkedin_mcp_server/scraping/link_metadata.py Adds "invitations": 100 to _REFERENCE_CAPS, fixing the previously-flagged early truncation at the default cap of 12
linkedin_mcp_server/tools/network.py New tool registration file; follows the established auth-error/raise_tool_error pattern correctly; Field(ge=1, le=100) validation properly constrains limit
linkedin_mcp_server/server.py Registers register_network_tools in create_mcp_server; correct import and call placement
tests/test_scraping.py TestGetPendingInvitations class adds 4 targeted tests covering reference trimming, sent-URL routing, zero-count early exit, and expand-selector correctness
tests/test_tools.py TestNetworkTools adds 4 tool-layer tests (happy path, sent kind, invalid kind, over-limit) and get_pending_invitations added to timeout-coverage tuples

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant MCP as MCP Client
    participant Tool as get_pending_invitations
    participant Ext as LinkedInExtractor
    participant Page as Browser Page

    MCP->>Tool: get_pending_invitations(limit, kind)
    Tool->>Ext: get_pending_invitations(limit, kind)
    Ext->>Page: "_navigate_to_page(/mynetwork/invitation-manager/{kind}/)"
    Ext->>Page: detect_rate_limit()
    Ext->>Page: _wait_for_main_text()
    Ext->>Page: handle_modal_close()
    Ext->>Page: "_scroll_main_scrollable_region(attempts=max(1, limit//10))"
    alt "kind == received"
        Ext->>Page: _received_invitation_count_is_zero()
        Page-->>Ext: True / False
    end
    alt count is zero
        Ext-->>Tool: "{url, sections:{}}"
    else "count > 0"
        Ext->>Page: _expand_invitation_note_toggles() [2 passes, 400ms each]
        Ext->>Page: _extract_root_content([main])
        Page-->>Ext: "{text, references}"
        Ext->>Ext: strip_linkedin_noise(raw)
        Ext->>Ext: build_references(...)[:limit]
        Ext-->>Tool: "{url, sections:{invitations:...}, references:{invitations:[...]}}"
    end
    Tool-->>MCP: result
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant MCP as MCP Client
    participant Tool as get_pending_invitations
    participant Ext as LinkedInExtractor
    participant Page as Browser Page

    MCP->>Tool: get_pending_invitations(limit, kind)
    Tool->>Ext: get_pending_invitations(limit, kind)
    Ext->>Page: "_navigate_to_page(/mynetwork/invitation-manager/{kind}/)"
    Ext->>Page: detect_rate_limit()
    Ext->>Page: _wait_for_main_text()
    Ext->>Page: handle_modal_close()
    Ext->>Page: "_scroll_main_scrollable_region(attempts=max(1, limit//10))"
    alt "kind == received"
        Ext->>Page: _received_invitation_count_is_zero()
        Page-->>Ext: True / False
    end
    alt count is zero
        Ext-->>Tool: "{url, sections:{}}"
    else "count > 0"
        Ext->>Page: _expand_invitation_note_toggles() [2 passes, 400ms each]
        Ext->>Page: _extract_root_content([main])
        Page-->>Ext: "{text, references}"
        Ext->>Ext: strip_linkedin_noise(raw)
        Ext->>Ext: build_references(...)[:limit]
        Ext-->>Tool: "{url, sections:{invitations:...}, references:{invitations:[...]}}"
    end
    Tool-->>MCP: result
Loading

Reviews (8): Last reviewed commit: "fix(network): omit empty invitation sugg..." | Re-trigger Greptile

Comment thread linkedin_mcp_server/scraping/extractor.py
Comment thread linkedin_mcp_server/scraping/extractor.py
Comment thread linkedin_mcp_server/scraping/extractor.py
ghul0 added a commit to ghul0/linkedin-mcp-server that referenced this pull request May 16, 2026
Three fixes for the Greptile review on stickerdaniel#447:

1. Trim returned references to the per-call `limit`. Previously `limit`
   only governed how many scroll passes ran; the build_references
   pipeline emitted every profile anchor it saw. References are now
   sliced to `[:limit]` after build_references runs, so a `limit=5`
   call cannot return more than five inviter references. The docstring
   now explicitly states that `sections` text may still include extra
   cards because LinkedIn pages invitations a screenful at a time.

2. Add an `invitations` entry to `_REFERENCE_CAPS` matching the tool's
   `Field(le=100)` ceiling. The previous fallback to the default cap
   of 12 silently truncated profile links for invitation lists larger
   than twelve, leaving most returned invitations without usable
   references.

3. Strengthen the expand-toggle selector so it skips
   `aria-expanded="true"` nodes in addition to the existing
   `data-mcp-clicked` marker. LinkedIn re-uses the same testid for
   the post-expand collapse control, so the previous selector could
   click an already-expanded note and silently re-truncate it. The
   collapsed state ships without an `aria-expanded` attribute at all
   on this surface, so `:not([aria-expanded="true"])` is the
   conservative shape: it matches the unset and explicit `"false"`
   states while skipping the expanded ones.
@ghul0

ghul0 commented May 16, 2026

Copy link
Copy Markdown
Author

Thanks for the careful review — all three P1 items addressed in 3920cb1.

1. Limit not enforced. References are now sliced to [:limit] after build_references, so a limit=5 call cannot return more than five inviter references. The sections text can still include slightly more cards because LinkedIn lazy-loads invitations a screenful at a time; the docstring now states this explicitly so callers don't get a surprise.

2. References truncated at the default cap of 12. Added an invitations entry to _REFERENCE_CAPS with a value of 100, matching the Field(le=100) ceiling on the tool. The per-call slice from item 1 is the operative ceiling in practice; the cap entry just removes the silent 12-link truncation at the build_references layer.

3. Expanded notes could collapse. Strengthened the expand-toggle selector to [data-testid="expandable-text-button"]:not([aria-expanded="true"]):not([data-mcp-clicked]). One nuance versus the inline suggestion: on this surface LinkedIn ships collapsed toggles without an aria-expanded attribute at all (verified during the original DOM debug pass — the inline suggestion used [aria-expanded="false"] which matches zero buttons in that state). The :not([aria-expanded="true"]) form is the conservative shape that matches both the unset and the explicit "false" states while skipping the expanded ones.

CI green on the new commit; full suite passes locally aside from one pre-existing umask-sensitive case in test_browser_security.py that reproduces on main unchanged.

ghul0 added a commit to ghul0/linkedin-mcp-server that referenced this pull request May 17, 2026
Three fixes for the Greptile review on stickerdaniel#447:

1. Trim returned references to the per-call `limit`. Previously `limit`
   only governed how many scroll passes ran; the build_references
   pipeline emitted every profile anchor it saw. References are now
   sliced to `[:limit]` after build_references runs, so a `limit=5`
   call cannot return more than five inviter references. The docstring
   now explicitly states that `sections` text may still include extra
   cards because LinkedIn pages invitations a screenful at a time.

2. Add an `invitations` entry to `_REFERENCE_CAPS` matching the tool's
   `Field(le=100)` ceiling. The previous fallback to the default cap
   of 12 silently truncated profile links for invitation lists larger
   than twelve, leaving most returned invitations without usable
   references.

3. Strengthen the expand-toggle selector so it skips
   `aria-expanded="true"` nodes in addition to the existing
   `data-mcp-clicked` marker. LinkedIn re-uses the same testid for
   the post-expand collapse control, so the previous selector could
   click an already-expanded note and silently re-truncate it. The
   collapsed state ships without an `aria-expanded` attribute at all
   on this surface, so `:not([aria-expanded="true"])` is the
   conservative shape: it matches the unset and explicit `"false"`
   states while skipping the expanded ones.
@ghul0 ghul0 force-pushed the feature/446-get-pending-invitations branch from 3920cb1 to b2b42c6 Compare May 17, 2026 06:36
ghul0 added 3 commits June 15, 2026 13:20
Read-only tool that lists pending LinkedIn network invitations from
/mynetwork/invitation-manager/{received|sent}/, mirroring the existing
get_inbox extractor pattern. Accept/ignore/withdraw actions are
intentionally out of scope for this PR.

Closes stickerdaniel#446
Click `data-testid="expandable-text-button"` toggles before extracting
text so the returned `sections["invitations"]` carries full invite
bodies instead of LinkedIn's "... see more" preview.

The testid attribute is the locale-independent signal — the visible
verb varies by locale and is unsafe to depend on per repo guidelines.

LinkedIn renders these buttons with inline `pointer-events: none`,
which blocks Playwright's standard click from reaching the React
handler. To work around that the implementation injects a scoped
one-shot stylesheet re-enabling pointer events on these specific
testid'd nodes and dispatches a synthetic bubbling MouseEvent so the
handler fires.

Buttons are tagged with `data-mcp-clicked` after the first dispatch
so a second pass picks up newly lazy-loaded cards without re-toggling
already-expanded notes (the post-click "collapse" button shares the
same testid).
Three fixes for the Greptile review on stickerdaniel#447:

1. Trim returned references to the per-call `limit`. Previously `limit`
   only governed how many scroll passes ran; the build_references
   pipeline emitted every profile anchor it saw. References are now
   sliced to `[:limit]` after build_references runs, so a `limit=5`
   call cannot return more than five inviter references. The docstring
   now explicitly states that `sections` text may still include extra
   cards because LinkedIn pages invitations a screenful at a time.

2. Add an `invitations` entry to `_REFERENCE_CAPS` matching the tool's
   `Field(le=100)` ceiling. The previous fallback to the default cap
   of 12 silently truncated profile links for invitation lists larger
   than twelve, leaving most returned invitations without usable
   references.

3. Strengthen the expand-toggle selector so it skips
   `aria-expanded="true"` nodes in addition to the existing
   `data-mcp-clicked` marker. LinkedIn re-uses the same testid for
   the post-expand collapse control, so the previous selector could
   click an already-expanded note and silently re-truncate it. The
   collapsed state ships without an `aria-expanded` attribute at all
   on this surface, so `:not([aria-expanded="true"])` is the
   conservative shape: it matches the unset and explicit `"false"`
   states while skipping the expanded ones.
@ghul0 ghul0 force-pushed the feature/446-get-pending-invitations branch from 78e6b0b to 5b1dccd Compare June 15, 2026 11:21
ghul0 added 2 commits June 17, 2026 17:00
Satisfies the CONTRIBUTING "Adding a New Tool" checklist item for
extractor-level coverage and locks the two review-feedback fixes so a
regression cannot silently reintroduce either:

- test_references_trimmed_to_limit: feeds 30 inviter anchors with
  limit=5 and asserts the returned references are sliced to 5 — guards
  the [:limit] fix and the invitations reference cap.
- test_expand_selector_skips_expanded_and_clicked: asserts the
  note-expansion selector keys on the locale-independent
  data-testid and carries both :not([aria-expanded="true"]) and
  :not([data-mcp-clicked]) guards, so a pass can never re-collapse a
  note it previously revealed.
- test_sent_kind_navigates_to_sent_page: covers the sent surface.

Helpers stubbed via patch.object to match the module's existing style.
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add get_pending_invitations tool for network invitations

1 participant