Skip to content

Feature/clipboard client - #4

Merged
ckaraca merged 9 commits into
mainfrom
feature/clipboard-client
Nov 29, 2025
Merged

ckaraca merged 9 commits into
mainfrom
feature/clipboard-client

Conversation

@ckaraca

@ckaraca ckaraca commented Nov 29, 2025 •

Copy link
Copy Markdown
Contributor

Summary by Sourcery

Add synchronous and asynchronous clipboard services and types to provide persistent key-value storage for tools and agents, and document their usage in the SDK README.

New Features:

  • Introduce typed clipboard domain models for entries, requests, responses, and related enums.
  • Expose new clipboard service APIs (sync and async) on the main SDK clients for managing clipboard entries, including stack-like operations (push/pop).

Enhancements:

  • Update public package exports to include clipboard types and services.
  • Extend README with clipboard usage documentation and examples, highlighting it as a core SDK capability.

Documentation:

  • Document clipboard operations, data model, and usage patterns in the README, including examples for set/get, stack operations, listing, deletion, and field semantics.

Add clipboard (memory) service with sync and async support:
- ClipboardService (sync) and AsyncClipboardService
- Named access: set, get (by name), delete
- Indexed access: push, pop, get (by index)
- List all entries, clear_all
- Full Pydantic model validation

Types:
- ClipboardEntry, ClipboardSetRequest, ClipboardPushRequest
- ClipboardGetFilters, ClipboardDeleteRequest
- ClipboardResponse, ClipboardDeleteResponse, ClipboardListResponse
- ClipboardEncoding and ClipboardVisibility enums
- Add ClipboardSource enum (ui/sdk/mcp)
- Add source field to ClipboardEntry model
- Hardcode source as 'sdk' in sync/async set/push methods
- Add DEFAULT_CLIPBOARD_SOURCE constant for backward compatibility
- Make ClipboardEntry.source Optional to handle older API responses
- Export constant from __init__.py

Addresses code review feedback about breaking existing call sites.
- Add Clipboard Operations section to README
- Document set, get, push, pop, list, and delete operations
- Add ClipboardEntry structure with source field
- Note that source is auto-set to ClipboardSource.SDK when using this SDK
- Add Clipboard/Memory to Features list
- Use 'is not None' check for ttl_seconds instead of truthiness
- Add explicit validation that ttl_seconds > 0 when provided
- Fix clear_all to only increment counter when delete() returns True
- Applied to both sync and async service classes (4 locations each)
- Import ClipboardSource from types
- Replace hardcoded "sdk" string with ClipboardSource.SDK.value
- Consistent with enum usage across the SDK
@sourcery-ai

sourcery-ai Bot commented Nov 29, 2025 •

Copy link
Copy Markdown

Reviewer's Guide

Adds a new clipboard domain to the SDK, including Pydantic models, sync/async service clients wired into the main client, and README documentation describing clipboard usage and entry structure.

Sequence diagram for synchronous clipboard set operation

sequenceDiagram
actor Developer
participant PluggedInClient
participant ClipboardService
participant ClipboardHTTPAPI
Developer->>PluggedInClient: create(api_key)
Developer->>PluggedInClient: clipboard.set(name,value,content_type,encoding,visibility,created_by_tool,created_by_model,ttl_seconds)
PluggedInClient->>ClipboardService: set(name,value,content_type,encoding,visibility,created_by_tool,created_by_model,ttl_seconds)
ClipboardService->>ClipboardService: _build_clipboard_payload(...)
ClipboardService->>ClipboardHTTPAPI: POST /api/clipboard
ClipboardHTTPAPI-->>ClipboardService: success, entry json
ClipboardService-->>PluggedInClient: ClipboardEntry
PluggedInClient-->>Developer: ClipboardEntry
Loading

Updated class diagram for clipboard domain models and services

classDiagram
class ClipboardEncoding {
  <<enumeration>>
  UTF8
  BASE64
  HEX
}
class ClipboardVisibility {
  <<enumeration>>
  PRIVATE
  WORKSPACE
  PUBLIC
}
class ClipboardSource {
  <<enumeration>>
  UI
  SDK
  MCP
}
class ClipboardEntry {
  uuid: str
  name: Optional[str]
  idx: Optional[int]
  value: str
  content_type: str
  encoding: ClipboardEncoding
  size_bytes: int
  visibility: ClipboardVisibility
  created_by_tool: Optional[str]
  created_by_model: Optional[str]
  source: Optional[ClipboardSource]
  created_at: datetime
  updated_at: datetime
  expires_at: Optional[datetime]
}
class ClipboardListResponse {
  success: bool
  entries: List[ClipboardEntry]
}
class ClipboardSetRequest {
  name: str
  value: str
  content_type: Optional[str]
  encoding: Optional[ClipboardEncoding]
  visibility: Optional[ClipboardVisibility]
  created_by_tool: Optional[str]
  created_by_model: Optional[str]
  ttl_seconds: Optional[int]
}
class ClipboardPushRequest {
  value: str
  content_type: Optional[str]
  encoding: Optional[ClipboardEncoding]
  visibility: Optional[ClipboardVisibility]
  created_by_tool: Optional[str]
  created_by_model: Optional[str]
  ttl_seconds: Optional[int]
}
class ClipboardGetFilters {
  name: Optional[str]
  idx: Optional[int]
  check_name_or_idx()
}
class ClipboardDeleteRequest {
  name: Optional[str]
  idx: Optional[int]
  check_name_or_idx()
}
class ClipboardResponse {
  success: bool
  entry: Optional[ClipboardEntry]
  error: Optional[str]
}
class ClipboardDeleteResponse {
  success: bool
  deleted: Optional[bool]
  error: Optional[str]
}
class ClipboardService {
  client: PluggedInClient
  list() List[ClipboardEntry]
  get(name: Optional[str], idx: Optional[int]) Optional[ClipboardEntry]
  set(name: str, value: str, content_type: str, encoding: ClipboardEncoding, visibility: ClipboardVisibility, created_by_tool: Optional[str], created_by_model: Optional[str], ttl_seconds: Optional[int]) ClipboardEntry
  push(value: str, content_type: str, encoding: ClipboardEncoding, visibility: ClipboardVisibility, created_by_tool: Optional[str], created_by_model: Optional[str], ttl_seconds: Optional[int]) ClipboardEntry
  pop() Optional[ClipboardEntry]
  delete(name: Optional[str], idx: Optional[int]) bool
  clear_all() int
}
class AsyncClipboardService {
  client: AsyncPluggedInClient
  list() List[ClipboardEntry]
  get(name: Optional[str], idx: Optional[int]) Optional[ClipboardEntry]
  set(name: str, value: str, content_type: str, encoding: ClipboardEncoding, visibility: ClipboardVisibility, created_by_tool: Optional[str], created_by_model: Optional[str], ttl_seconds: Optional[int]) ClipboardEntry
  push(value: str, content_type: str, encoding: ClipboardEncoding, visibility: ClipboardVisibility, created_by_tool: Optional[str], created_by_model: Optional[str], ttl_seconds: Optional[int]) ClipboardEntry
  pop() Optional[ClipboardEntry]
  delete(name: Optional[str], idx: Optional[int]) bool
  clear_all() int
}
class PluggedInClient
class AsyncPluggedInClient
ClipboardEntry --> ClipboardEncoding
ClipboardEntry --> ClipboardVisibility
ClipboardEntry --> ClipboardSource
ClipboardListResponse --> ClipboardEntry
ClipboardResponse --> ClipboardEntry
ClipboardDeleteResponse --> ClipboardEntry
ClipboardService --> PluggedInClient
AsyncClipboardService --> AsyncPluggedInClient
Loading

File-Level Changes

Change Details Files
Introduce clipboard domain models for entries, requests, responses, and validation.
  • Extend types module with enums for clipboard encoding, visibility, and source plus a default source constant
  • Add ClipboardEntry model with aliasing, default values, and backward-compatible optional source field
  • Define request/response models for listing, setting, pushing, getting, deleting clipboard entries, including validators enforcing name-or-idx constraints
src/pluggedinkit/types.py
Expose clipboard models and services through the package API and wire them into sync/async clients.
  • Re-export clipboard models and enums from the package root so users can import them directly
  • Add ClipboardService and AsyncClipboardService to services package exports
  • Attach clipboard service instances to PluggedInClient and AsyncPluggedInClient constructors
src/pluggedinkit/__init__.py
src/pluggedinkit/services/__init__.py
src/pluggedinkit/client.py
Implement synchronous and asynchronous clipboard service clients wrapping the HTTP API.
  • Add shared helper to build clipboard payloads with encoding/visibility normalization, source tagging, and TTL validation
  • Implement sync ClipboardService methods for list/get/set/push/pop/delete/clear_all with error handling and response parsing
  • Implement AsyncClipboardService with async equivalents of all sync methods mirroring behavior and return types
src/pluggedinkit/services/clipboard.py
Document clipboard capabilities and usage in README.
  • Add clipboard/memory feature bullet to capabilities list
  • Document clipboard operations with examples for set/get/push/pop/list/delete/clear_all
  • Describe ClipboardEntry structure and explain automatic source field behavior
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • The sync and async clipboard services duplicate a lot of logic (payload building, error handling, clear_all implementation); consider extracting shared helpers to reduce maintenance overhead and keep behavior perfectly in sync.
  • The get and delete methods accept both name and idx simultaneously; consider validating that exactly one is provided (or defining a clear precedence) to avoid ambiguous calls.
  • Service methods like set and push accept encoding and visibility as plain strings while you have strongly-typed enums (ClipboardEncoding, ClipboardVisibility); consider using those enums in the public API for better type safety and to catch invalid values earlier.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The sync and async clipboard services duplicate a lot of logic (payload building, error handling, clear_all implementation); consider extracting shared helpers to reduce maintenance overhead and keep behavior perfectly in sync.
- The `get` and `delete` methods accept both `name` and `idx` simultaneously; consider validating that exactly one is provided (or defining a clear precedence) to avoid ambiguous calls.
- Service methods like `set` and `push` accept `encoding` and `visibility` as plain strings while you have strongly-typed enums (`ClipboardEncoding`, `ClipboardVisibility`); consider using those enums in the public API for better type safety and to catch invalid values earlier.

## Individual Comments

### Comment 1
<location> `src/pluggedinkit/types.py:354-301` </location>
<code_context>
+        populate_by_name = True
+
+
+class ClipboardGetFilters(BaseModel):
+    """Filters for getting a clipboard entry"""
+    name: Optional[str] = None
+    idx: Optional[int] = None
+
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider validating that at least one of `name` or `idx` is provided in filter/delete models.

`ClipboardGetFilters` (and similarly `ClipboardDeleteRequest`) allow both `name` and `idx` to be `None`, but the service methods reject that case. This lets callers build models that will later fail at the service layer. Consider adding a `model_validator` (or similar) so the models enforce "at least one of `name` or `idx`" directly, keeping them consistent with the service behavior.
</issue_to_address>

### Comment 2
<location> `src/pluggedinkit/services/clipboard.py:39-33` </location>
<code_context>
+        list_response = ClipboardListResponse(**data)
+        return list_response.entries
+
+    def get(
+        self,
+        name: Optional[str] = None,
+        idx: Optional[int] = None,
+    ) -> Optional[ClipboardEntry]:
+        """Get a clipboard entry by name or index"""
+        if name is None and idx is None:
+            raise ValueError("Either 'name' or 'idx' must be provided")
+
+        params = {}
+        if name is not None:
+            params["name"] = name
+        if idx is not None:
+            params["idx"] = str(idx)
+
+        response = self.client.request("GET", "/api/clipboard", params=params)
+        data = response.json()
+
+        if not data.get("success"):
+            return None
+
</code_context>

<issue_to_address>
**question (bug_risk):** Error handling in `get` differs from the rest of the service API and may be surprising.

Here, non-success responses return `None`, while other methods (`list`, `set`, `push`) raise `PluggedInError` on failure. This makes it hard for callers to distinguish "not found" from other errors and forces them to interpret `success` themselves.

If the API can differentiate not-found from other errors, consider returning `None` only for not-found and raising an exception for other failures, to stay consistent with the rest of the service.
</issue_to_address>

### Comment 3
<location> `src/pluggedinkit/services/clipboard.py:64-70` </location>
<code_context>
+            return ClipboardEntry(**data["entry"])
+        return None
+
+    def set(
+        self,
+        name: str,
+        value: str,
+        content_type: str = "text/plain",
+        encoding: str = "utf-8",
+        visibility: str = "private",
+        created_by_tool: Optional[str] = None,
+        created_by_model: Optional[str] = None,
</code_context>

<issue_to_address>
**suggestion:** Leverage existing enums for `encoding`/`visibility` in the service interface for stronger typing.

The `set`/`push` parameters currently take `str` for `encoding` and `visibility`, even though `ClipboardEncoding` and `ClipboardVisibility` are already defined and used in the models. Accepting these enums (or `Union[str, ClipboardEncoding]` / `Union[str, ClipboardVisibility]`) would improve type safety and editor support while keeping compatibility with raw strings, and keep the service API consistent with the Pydantic model constraints.
</issue_to_address>

### Comment 4
<location> `src/pluggedinkit/services/clipboard.py:22` </location>
<code_context>
+    from ..client import AsyncPluggedInClient, PluggedInClient
+
+
+class ClipboardService:
+    """Synchronous clipboard service"""
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the clipboard services to share sync/async logic and rely on existing Pydantic request/response models instead of manual dict-building and validation.

The main complexity comes from duplicated sync/async logic and hand-rolled payload/validation that the existing Pydantic models could handle for you.

A few concrete refactors that reduce duplication while keeping behavior intact:

---

### 1. Share payload-building logic between sync/async

`set`/`push` are copy-pasted with the only difference being `await`. You can centralize payload construction and reuse it from both classes:

```python
# clipboard.py (module-level helpers)

def _build_set_payload(
    name: str,
    value: str,
    content_type: str,
    encoding: str,
    visibility: str,
    created_by_tool: Optional[str],
    created_by_model: Optional[str],
    ttl_seconds: Optional[int],
) -> dict:
    req = ClipboardSetRequest(
        name=name,
        value=value,
        content_type=content_type,
        encoding=encoding,
        visibility=visibility,
        source=ClipboardSource.SDK,  # or ClipboardSource.SDK.value if model expects string
        created_by_tool=created_by_tool,
        created_by_model=created_by_model,
        ttl_seconds=ttl_seconds,
    )
    return req.model_dump(by_alias=True, exclude_none=True)
```

Then in both services:

```python
def set(...):
    payload = _build_set_payload(
        name=name,
        value=value,
        content_type=content_type,
        encoding=encoding,
        visibility=visibility,
        created_by_tool=created_by_tool,
        created_by_model=created_by_model,
        ttl_seconds=ttl_seconds,
    )
    response = self.client.request("POST", "/api/clipboard", json=payload)
    ...
```

```python
async def set(...):
    payload = _build_set_payload(...)
    response = await self.client.request("POST", "/api/clipboard", json=payload)
    ...
```

Same idea for `push` via `_build_push_payload(...)`.

This removes repeated `ttl_seconds` checks and manual `"contentType"` / `"createdByTool"` keys; the Pydantic model already validates `ttl_seconds` and handles aliases.

---

### 2. Use request models for `get`/`delete` instead of ad-hoc dicts

You’re manually building `params`/`payload` and re-implementing validation like “either name or idx must be provided”. That’s exactly what `ClipboardGetFilters` and `ClipboardDeleteRequest` are for.

Example for `get`:

```python
# helper
def _build_get_params(
    name: Optional[str],
    idx: Optional[int],
) -> dict:
    filters = ClipboardGetFilters(name=name, idx=idx)
    return filters.model_dump(exclude_none=True)
```

Then:

```python
def get(self, name: Optional[str] = None, idx: Optional[int] = None) -> Optional[ClipboardEntry]:
    params = _build_get_params(name, idx)
    response = self.client.request("GET", "/api/clipboard", params=params)
    data = response.json()
    ...
```

```python
async def get(...):
    params = _build_get_params(name, idx)
    response = await self.client.request("GET", "/api/clipboard", params=params)
    ...
```

Same pattern for `delete` using `ClipboardDeleteRequest`:

```python
def _build_delete_payload(name: Optional[str], idx: Optional[int]) -> dict:
    req = ClipboardDeleteRequest(name=name, idx=idx)
    return req.model_dump(exclude_none=True)
```

---

### 3. Use response models instead of manual `success`/`entry` handling

You already import `ClipboardResponse`, `ClipboardDeleteResponse`, `ClipboardListResponse`. They can replace the inline `data.get(...)` checks and `ClipboardEntry(**data["entry"])` calls.

Example for `set` (sync version):

```python
response = self.client.request("POST", "/api/clipboard", json=payload)
data = response.json()

resp = ClipboardResponse(**data)
if not resp.success:
    raise PluggedInError(resp.error or "Failed to set clipboard entry")

return resp.entry
```

Same code in async (just `await` the request). Similar simplification is possible in `push`, `pop`, `delete`, and `list` with their respective response models.

---

### 4. Deduplicate `clear_all` with a shared helper

The `clear_all` loops are identical apart from `await`. You can centralize the core logic and pass in a delete function; this keeps behavior and error swallowing identical.

A small, focused helper that takes a list and a delete callable:

```python
def _clear_entries_sync(entries: List[ClipboardEntry], delete_fn) -> int:
    deleted = 0
    for entry in entries:
        try:
            success = False
            if entry.name:
                success = delete_fn(name=entry.name)
            elif entry.idx is not None:
                success = delete_fn(idx=entry.idx)
            if success:
                deleted += 1
        except PluggedInError:
            pass
    return deleted
```

Then:

```python
def clear_all(self) -> int:
    entries = self.list()
    return _clear_entries_sync(entries, self.delete)
```

The async version can mirror this with a tiny async helper:

```python
async def _clear_entries_async(entries: List[ClipboardEntry], delete_fn) -> int:
    deleted = 0
    for entry in entries:
        try:
            success = False
            if entry.name:
                success = await delete_fn(name=entry.name)
            elif entry.idx is not None:
                success = await delete_fn(idx=entry.idx)
            if success:
                deleted += 1
        except PluggedInError:
            pass
    return deleted

async def clear_all(self) -> int:
    entries = await self.list()
    return await _clear_entries_async(entries, self.delete)
```

---

Applying these patterns will:

- Remove duplicated parameter/validation logic across sync/async.
- Centralize schema and validation in the existing Pydantic models.
- Minimize manual dict-building and repeated `ttl_seconds` checks.
- Keep the public API and wire protocol behavior exactly as in your current implementation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/pluggedinkit/types.py
Comment thread src/pluggedinkit/services/clipboard.py Outdated
response = self.client.request("GET", "/api/clipboard")
data = response.json()

if not data.get("success"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Error handling in get differs from the rest of the service API and may be surprising.

Here, non-success responses return None, while other methods (list, set, push) raise PluggedInError on failure. This makes it hard for callers to distinguish "not found" from other errors and forces them to interpret success themselves.

If the API can differentiate not-found from other errors, consider returning None only for not-found and raising an exception for other failures, to stay consistent with the rest of the service.

Comment thread src/pluggedinkit/services/clipboard.py
@claude

claude Bot commented Nov 29, 2025

Copy link
Copy Markdown

Pull Request Review: Feature/clipboard client

Overview

This PR adds clipboard functionality to the SDK, providing persistent key-value storage for MCP tools and AI agents. The implementation follows good patterns and is generally well-structured.


Code Quality and Best Practices

Strengths

  1. Consistent Architecture: Follows established pattern used in documents.py
  2. Type Safety: Proper use of Pydantic models with field aliases
  3. Dual API Support: Both sync and async implementations
  4. Clear Documentation: Comprehensive README examples
  5. Proper Enum Usage: ClipboardEncoding, ClipboardVisibility, ClipboardSource

Issues & Suggestions

1. Inconsistent Return Type in list() method
Location: clipboard.py:28, 192

The list() method returns List[ClipboardEntry] but README example line 435 treats it as having .entries attribute - this would cause runtime errors.

Fix: Update README to use entries = client.clipboard.list() or change method to return ClipboardListResponse.

2. Missing Input Validation
Location: clipboard.py:76-91, 238-253

The set() method doesn't validate name parameter length (max 255 chars). Add validation before API call.

3. Silent Error Handling in clear_all()
Location: clipboard.py:169-183, 333-347

Silently catches PluggedInError exceptions, potentially masking auth failures or network issues.


Potential Bugs

1. Race Condition in clear_all() - Lists then deletes one-by-one. Concurrent operations could cause failures.

2. API Response Structure Mismatch - get() method needs better error handling for unexpected API responses.

3. Unused Type Definitions - ClipboardSetRequest, ClipboardPushRequest, ClipboardGetFilters, etc. are defined but never used. Either use them or remove.


Performance Considerations

1. Inefficient clear_all() - Makes N+1 API calls. Consider bulk delete endpoint or pagination.

2. Missing Pagination - list() fetches all entries at once. Could cause memory issues for large clipboards.


Security Concerns

1. No TTL Upper Bound - Add max validation (e.g., 1 year max)

2. No Content Size Validation - Large values could cause memory issues. Add size limit (e.g., 10MB max)

3. Source Field Override - Document hardcoded SDK source in method docstrings


Test Coverage - CRITICAL

The PR includes no tests for clipboard functionality. This is a blocker for production code.

Essential test cases needed:

  1. Basic CRUD operations
  2. Error handling
  3. Edge cases
  4. Type validation
  5. Async/sync parity

Summary

Must Fix Before Merge

  1. Fix inconsistent return type in list() and update README
  2. Add comprehensive test coverage
  3. Handle errors in clear_all() gracefully

Should Fix

  1. Add input validation for name length and value size
  2. Add TTL upper bound validation
  3. Improve error handling in get() method
  4. Consider pagination for list() method

Nice to Have

  1. Use defined Pydantic models or remove unused ones
  2. Add method docstrings with examples
  3. Bulk delete endpoint for clear_all()

Recommendation: Request changes for test coverage and critical bugs before merging.

Great work overall! With these improvements, this will be production-ready.

- Add model_validator to ClipboardGetFilters for name/idx constraint
- Add model_validator to ClipboardDeleteRequest for name/idx constraint
- Use ClipboardEncoding enum for encoding parameter
- Use ClipboardVisibility enum for visibility parameter
- Extract _build_clipboard_payload helper to reduce sync/async duplication
@ckaraca

ckaraca commented Nov 29, 2025

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • The enum handling in _build_clipboard_payload is a bit redundant: callers already normalize encoding/visibility to ClipboardEncoding/ClipboardVisibility, and then _build_clipboard_payload checks/normalizes them again—consider consolidating this so the conversion happens in only one place.
  • Error handling behavior is inconsistent across clipboard methods (list/set/push raise on failure while get/pop/delete silently return None/False); it may be worth aligning these semantics or at least distinguishing between a genuine error and a simple ‘not found’ case.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The enum handling in `_build_clipboard_payload` is a bit redundant: callers already normalize `encoding`/`visibility` to `ClipboardEncoding`/`ClipboardVisibility`, and then `_build_clipboard_payload` checks/normalizes them again—consider consolidating this so the conversion happens in only one place.
- Error handling behavior is inconsistent across clipboard methods (`list`/`set`/`push` raise on failure while `get`/`pop`/`delete` silently return `None`/`False`); it may be worth aligning these semantics or at least distinguishing between a genuine error and a simple ‘not found’ case.

## Individual Comments

### Comment 1
<location> `src/pluggedinkit/services/clipboard.py:189-198` </location>
<code_context>
+
+        return data.get("success", False) and data.get("deleted", False)
+
+    def clear_all(self) -> int:
+        """Clear all clipboard entries. Returns count of deleted entries."""
+        entries = self.list()
+        deleted = 0
+
+        for entry in entries:
+            try:
+                success = False
+                if entry.name:
+                    success = self.delete(name=entry.name)
+                elif entry.idx is not None:
+                    success = self.delete(idx=entry.idx)
+                if success:
+                    deleted += 1
+            except PluggedInError:
+                pass
+
+        return deleted
+
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** The clear_all implementation may be inefficient for large clipboards and silently swallows errors.

`clear_all` currently deletes entries one by one and ignores `PluggedInError` inside the loop. This can be slow with many entries and hides partial failures from callers.

If available, prefer a bulk delete API. Otherwise, consider at least logging/aggregating failures or returning structured results (e.g., total, succeeded, failed) so callers can detect and handle partial deletions.
</issue_to_address>

### Comment 2
<location> `src/pluggedinkit/services/clipboard.py:57` </location>
<code_context>
+    return payload
+
+
+class ClipboardService:
+    """Synchronous clipboard service"""
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring shared logic and input validation into small helper functions and existing models so the sync and async clipboard services stay thin and less duplicated while preserving behavior.

You can reduce complexity without changing behavior by centralizing the shared logic and normalizing inputs in one place.

### 1. Factor out shared sync/async request logic

Most methods differ only by `await`. You can extract common helpers that take a callable `request` (sync or async):

```python
def _build_params(name: Optional[str], idx: Optional[int]) -> dict:
    if name is None and idx is None:
        raise ValueError("Either 'name' or 'idx' must be provided")

    params: dict = {}
    if name is not None:
        params["name"] = name
    if idx is not None:
        params["idx"] = str(idx)
    return params
```

```python
def _parse_list_response(data: dict) -> List[ClipboardEntry]:
    if not data.get("success"):
        raise PluggedInError(data.get("error", "Failed to list clipboard entries"))
    return ClipboardListResponse(**data).entries
```

Then in services:

```python
class ClipboardService:
    def list(self) -> List[ClipboardEntry]:
        response = self.client.request("GET", "/api/clipboard")
        return _parse_list_response(response.json())

    def get(self, name: Optional[str] = None, idx: Optional[int] = None) -> Optional[ClipboardEntry]:
        params = _build_params(name, idx)
        response = self.client.request("GET", "/api/clipboard", params=params)
        data = response.json()
        if not data.get("success") or not data.get("entry"):
            return None
        return ClipboardEntry(**data["entry"])
```

```python
class AsyncClipboardService:
    async def list(self) -> List[ClipboardEntry]:
        response = await self.client.request("GET", "/api/clipboard")
        return _parse_list_response(response.json())

    async def get(self, name: Optional[str] = None, idx: Optional[int] = None) -> Optional[ClipboardEntry]:
        params = _build_params(name, idx)
        response = await self.client.request("GET", "/api/clipboard", params=params)
        data = response.json()
        if not data.get("success") or not data.get("entry"):
            return None
        return ClipboardEntry(**data["entry"])
```

You can apply the same pattern to `set`, `push`, `delete`, and `pop` by extracting small helpers like `_parse_entry_response(data, default_none_ok: bool)` and `_parse_delete_response(data) -> bool`. That keeps sync/async classes thin but preserves the public API.

### 2. Centralize encoding/visibility normalization in `_build_clipboard_payload`

Right now you normalize enums at call sites and again inside `_build_clipboard_payload`. You can move that logic into `_build_clipboard_payload` and pass through whatever the caller received:

```python
from typing import Union

def _build_clipboard_payload(
    value: str,
    name: Optional[str] = None,
    content_type: str = "text/plain",
    encoding: Union[ClipboardEncoding, str] = ClipboardEncoding.UTF8,
    visibility: Union[ClipboardVisibility, str] = ClipboardVisibility.PRIVATE,
    created_by_tool: Optional[str] = None,
    created_by_model: Optional[str] = None,
    ttl_seconds: Optional[int] = None,
) -> dict:
    if not isinstance(encoding, ClipboardEncoding):
        encoding = ClipboardEncoding(encoding)
    if not isinstance(visibility, ClipboardVisibility):
        visibility = ClipboardVisibility(visibility)

    payload = {
        "value": value,
        "contentType": content_type,
        "encoding": encoding.value,
        "visibility": visibility.value,
        "source": ClipboardSource.SDK.value,
    }
    # ... rest unchanged ...
    return payload
```

Then your service methods become simpler:

```python
def set(
    self,
    name: str,
    value: str,
    content_type: str = "text/plain",
    encoding: Union[ClipboardEncoding, str] = ClipboardEncoding.UTF8,
    visibility: Union[ClipboardVisibility, str] = ClipboardVisibility.PRIVATE,
    created_by_tool: Optional[str] = None,
    created_by_model: Optional[str] = None,
    ttl_seconds: Optional[int] = None,
) -> ClipboardEntry:
    payload = _build_clipboard_payload(
        value=value,
        name=name,
        content_type=content_type,
        encoding=encoding,
        visibility=visibility,
        created_by_tool=created_by_tool,
        created_by_model=created_by_model,
        ttl_seconds=ttl_seconds,
    )
    # request + parsing unchanged
```

Same change can be applied to `push` in both sync and async services.

### 3. Reuse existing Pydantic models for `get`/`delete` validation

Since you already have `ClipboardGetFilters` / `ClipboardDeleteRequest`, you can offload the “at least one of name or idx” validation there instead of duplicating it in methods:

```python
def delete(self, name: Optional[str] = None, idx: Optional[int] = None) -> bool:
    req = ClipboardDeleteRequest(name=name, idx=idx)
    response = self.client.request("DELETE", "/api/clipboard", json=req.model_dump())
    data = response.json()
    return data.get("success", False) and data.get("deleted", False)
```

```python
async def delete(self, name: Optional[str] = None, idx: Optional[int] = None) -> bool:
    req = ClipboardDeleteRequest(name=name, idx=idx)
    response = await self.client.request("DELETE", "/api/clipboard", json=req.model_dump())
    data = response.json()
    return data.get("success", False) and data.get("deleted", False)
```

You can do the same for `get` with `ClipboardGetFilters`, which will remove the manual `ValueError` checks and keep validation in one place.

### 4. Extract `_delete_entry` helper for `clear_all`

`clear_all` is fine functionally, but a tiny helper reduces branching and duplication and can be shared between sync and async:

```python
def _entry_delete_args(entry: ClipboardEntry) -> dict:
    if entry.name:
        return {"name": entry.name}
    if entry.idx is not None:
        return {"idx": entry.idx}
    return {}
```

```python
def clear_all(self) -> int:
    entries = self.list()
    deleted = 0

    for entry in entries:
        args = _entry_delete_args(entry)
        if not args:
            continue
        try:
            if self.delete(**args):
                deleted += 1
        except PluggedInError:
            # Optional: log here if desired
            pass

    return deleted
```

```python
async def clear_all(self) -> int:
    entries = await self.list()
    deleted = 0

    for entry in entries:
        args = _entry_delete_args(entry)
        if not args:
            continue
        try:
            if await self.delete(**args):
                deleted += 1
        except PluggedInError:
            # Optional: log here if desired
            pass

    return deleted
```

These changes keep all functionality and public signatures intact while reducing duplication and making the code easier to evolve.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/pluggedinkit/services/clipboard.py
- Consolidate enum normalization to _build_clipboard_payload only
- Extract shared helper functions (_parse_list_response, _parse_entry_response, etc.)
- Use Pydantic models (ClipboardGetFilters, ClipboardDeleteRequest) for validation
- Fix clear_all to use bulk delete API with clearAll=true instead of one-by-one
- Add ClearAllResult dataclass for structured feedback on bulk operations
- Add explicit type annotations (Dict[str, Any]) to fix mypy errors
- Change delete() return type from bool to int (deleted count)
- Add consistent error handling with raise_on_failure parameter

Key improvements:
- Single place for enum normalization (no more redundant checks)
- Shared helpers reduce duplication between sync/async services
- Pydantic validators handle "at least one of name/idx" checks
- Bulk delete is efficient for large clipboards
- ClearAllResult provides deleted/failed counts and success property
@claude

claude Bot commented Nov 29, 2025

Copy link
Copy Markdown

Pull Request Review: Feature/clipboard client

Summary

This PR introduces a new clipboard service feature to the SDK, adding persistent key-value and stack-based storage for MCP tools and AI agents. The implementation is well-structured and follows the existing codebase patterns.

✅ Strengths

Code Quality

  • Excellent code organization: Clear separation between sync/async implementations with shared helper functions
  • Consistent with existing patterns: Follows the same structure as DocumentService and other services
  • Type safety: Comprehensive use of Pydantic models and type hints throughout
  • Documentation: Well-documented with clear docstrings for all public methods
  • DRY principle: Good use of helper functions to avoid code duplication (_build_clipboard_payload, _parse_entry_response, etc.)

Design Decisions

  • Flexible enum handling: _normalize_encoding() and _normalize_visibility() allow users to pass either enum or string values
  • Graceful error handling: Appropriate use of raise_on_failure parameter to differentiate between operations that should raise vs return None
  • Clear API: The ClearAllResult dataclass provides structured feedback with useful properties

🔍 Issues & Recommendations

1. Critical: Incomplete type validation in types.py (lines 359-363, 372-375)

The validator uses mode='after' but references self which should work, but there's a truncated line at the end of the diff. Let me verify the complete implementation:

Issue: Line 363 shows return sel instead of return self - this appears to be a truncation in the diff, but needs verification.

Recommendation: Ensure the validator returns self properly:

@model_validator(mode='after')
def check_name_or_idx(self) -> 'ClipboardDeleteRequest':
    if self.name is None and self.idx is None:
        raise ValueError("Either 'name' or 'idx' must be provided")
    return self  # Not 'sel'

2. Potential Bug: Inconsistent error response handling (clipboard.py:127-136)

The _parse_delete_response() function silently returns 0 on failure, which could mask actual errors from the API.

Current behavior:

def _parse_delete_response(data: Dict[str, Any]) -> int:
    if not data.get("success"):
        return 0  # Silently fails
    deleted = data.get("deleted", 0)
    return int(deleted) if deleted is not None else 0

Recommendation: Consider raising an exception or logging when the API returns an error:

def _parse_delete_response(data: Dict[str, Any]) -> int:
    if not data.get("success"):
        error_msg = data.get("error", "Failed to delete clipboard entry")
        raise PluggedInError(error_msg)
    deleted = data.get("deleted", 0)
    return int(deleted) if deleted is not None else 0

Or at minimum, distinguish between "not found" and "error" cases.

3. Code Smell: Unnecessary assertions (clipboard.py:289, 336, 499, 546)

The code uses assert result is not None after calling _parse_entry_response() with raise_on_failure=True.

Issue: These assertions are redundant since the function will raise an exception if it fails. Assertions can also be disabled with Python's -O flag.

Recommendation: Remove the assertions or add a comment explaining they're for type checker satisfaction:

result = _parse_entry_response(
    response.json(),
    error_msg="Failed to set clipboard entry",
    raise_on_failure=True,
)
# Type narrowing: _parse_entry_response with raise_on_failure=True never returns None
assert result is not None
return result

Or better, adjust the function signature to reflect this:

@overload
def _parse_entry_response(
    data: Dict[str, Any], error_msg: str, raise_on_failure: Literal[True]
) -> ClipboardEntry: ...

@overload
def _parse_entry_response(
    data: Dict[str, Any], error_msg: str, raise_on_failure: Literal[False]
) -> Optional[ClipboardEntry]: ...

4. Performance: README examples show synchronous list iteration (README.md:435-438)

The clipboard list example could mislead users about the return type:

response = client.clipboard.list()
for entry in response.entries:  # 'entries' attribute doesn't exist

Issue: list() returns List[ClipboardEntry], not a ClipboardListResponse object.

Actual implementation (clipboard.py:201-211):

def list(self) -> List[ClipboardEntry]:
    response = self.client.request("GET", "/api/clipboard")
    return _parse_list_response(response.json())  # Returns .entries

Recommendation: Fix the documentation:

# List all entries
entries = client.clipboard.list()
for entry in entries:
    label = entry.name or f"idx:{entry.idx}"
    print(f"{label} - source: {entry.source}")

5. Security: No validation on value size

The clipboard service doesn't validate the size of value before sending to the API.

Recommendation: Add size validation in _build_clipboard_payload():

def _build_clipboard_payload(
    value: str,
    # ... other params ...
) -> Dict[str, Any]:
    # Add reasonable size limit (e.g., 1MB for clipboard values)
    MAX_VALUE_SIZE = 1024 * 1024  # 1MB
    if len(value.encode('utf-8')) > MAX_VALUE_SIZE:
        raise ValueError(f"Value size exceeds maximum of {MAX_VALUE_SIZE} bytes")
    # ... rest of implementation

6. Best Practice: Missing input validation

The content_type parameter accepts any string without validation.

Recommendation: Add basic MIME type validation or at least length limits:

if content_type and len(content_type) > 256:
    raise ValueError("content_type must be 256 characters or less")

🧪 Test Coverage

Critical Issue: No test files were added with this PR.

Recommendation: Add comprehensive tests covering:

  • ✅ Basic CRUD operations (set, get, delete, list)
  • ✅ Stack operations (push, pop)
  • ✅ Error cases (invalid inputs, API failures)
  • ✅ Enum normalization
  • ✅ TTL validation
  • ✅ Both sync and async implementations
  • ✅ Edge cases (empty clipboard, concurrent operations)

Example test structure:

# tests/test_clipboard.py
import pytest
from pluggedinkit import PluggedInClient, ClipboardEncoding

def test_clipboard_set_get(client):
    entry = client.clipboard.set("test_key", "test_value")
    assert entry.name == "test_key"
    
    retrieved = client.clipboard.get(name="test_key")
    assert retrieved.value == "test_value"

def test_clipboard_ttl_validation():
    with pytest.raises(ValueError, match="ttl_seconds must be greater than 0"):
        client.clipboard.set("key", "value", ttl_seconds=0)

🔒 Security Considerations

Positive

  • ✅ Source field is automatically set to ClipboardSource.SDK to track entry origin
  • ✅ Visibility controls are implemented (private/workspace/public)
  • ✅ Uses Pydantic for input validation

Concerns

  • ⚠️ No apparent rate limiting specific to clipboard operations
  • ⚠️ No size validation on clipboard values (could allow large payloads)
  • ⚠️ TTL is optional - entries could persist indefinitely by default

📊 Performance Considerations

  1. clear_all() implementation (clipboard.py:376-396): Uses a single API call with clearAll: true, which is efficient. Good design.

  2. list() returns full list: For users with many clipboard entries, this could be memory-intensive. Consider adding pagination in future iterations.

🎯 Summary

This is a well-implemented feature that follows good practices and maintains consistency with the existing codebase. The main concerns are:

  1. Missing test coverage (critical)
  2. Documentation error in README (line 435)
  3. Potential truncation issue in types.py validator
  4. Silent error handling in delete operations
  5. Missing input validation for value sizes

Recommended Actions Before Merge

  1. ✅ Add comprehensive test coverage
  2. ✅ Fix README documentation for list() return type
  3. ✅ Verify types.py validators are complete
  4. ✅ Consider improving error handling in _parse_delete_response()
  5. ✅ Add value size validation
  6. ⚠️ Consider adding type overloads for better type safety (optional)

Great work overall! The feature is production-ready once the above items are addressed.

@ckaraca
ckaraca merged commit 7e18607 into main Nov 29, 2025
6 checks passed
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.

1 participant