Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### platform v1.1.4

#### Fixed
- **`upload-artifact` script mode now works against MCP Python SDK 2.x.** `upload_artifact.py` unpacked three values from `streamable_http_client`, read `result.isError`, and built its authenticated client with `httpx` — all 1.x-era shapes. Under SDK 2.x the transport yields a two-value `(read_stream, write_stream)` tuple, `CallToolResult` exposes `is_error`, and the transport expects an `httpx2.AsyncClient`; because the documented `uv run --with 'mcp[cli]'` invocation was unpinned it resolved to 2.x, so every run failed at connect time. The script now targets the 2.x API, and both the module docstring and `SKILL.md` pin the invocation to `mcp[cli]>=2,<3`. Connection setup shared by `--list-projects` and upload is consolidated into a single `_connect` async context manager, and the `get-document` error path reuses the existing `_error_details` helper instead of rebuilding the detail list. The repo dev dependency group moves to `mcp==2.0.0` so type checking runs against the same SDK the script requires.

### code-review v3.7.0

#### Changed
Expand Down
2 changes: 1 addition & 1 deletion plugins/platform/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "platform",
"description": "ClosedLoop Platform plugin",
"version": "1.1.3",
"version": "1.1.4",
"author": {
"name": "ClosedLoop",
"email": "support@closedloop.ai"
Expand Down
4 changes: 2 additions & 2 deletions plugins/platform/skills/upload-artifact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ otherwise rely on a `.env.local` file in the current working directory:
Run the script with `--list-projects`:

```bash
uv run --with 'mcp[cli]' ${CLAUDE_SKILL_DIR}/scripts/upload_artifact.py \
uv run --with 'mcp[cli]>=2,<3' ${CLAUDE_SKILL_DIR}/scripts/upload_artifact.py \
--url "$NEXT_PUBLIC_MCP_SERVER_URL" \
--api-key "$CLOSEDLOOP_API_KEY" \
--list-projects
Expand Down Expand Up @@ -76,7 +76,7 @@ type — only ask for the title.
### Step 4a: Upload via Script

```bash
uv run --with 'mcp[cli]' ${CLAUDE_SKILL_DIR}/scripts/upload_artifact.py \
uv run --with 'mcp[cli]>=2,<3' ${CLAUDE_SKILL_DIR}/scripts/upload_artifact.py \
--url "$NEXT_PUBLIC_MCP_SERVER_URL" \
--api-key "$CLOSEDLOOP_API_KEY" \
--file <FILE_PATH> \
Expand Down
98 changes: 45 additions & 53 deletions plugins/platform/skills/upload-artifact/scripts/upload_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@
export NEXT_PUBLIC_MCP_SERVER_URL=https://example.com/mcp

# List available projects:
uv run --with 'mcp[cli]' scripts/upload_artifact.py \\
uv run --with 'mcp[cli]>=2,<3' scripts/upload_artifact.py \\
--list-projects

# Create document:
uv run --with 'mcp[cli]' scripts/upload_artifact.py \\
uv run --with 'mcp[cli]>=2,<3' scripts/upload_artifact.py \\
--file /path/to/content.md \\
--title "My PRD" \\
--type PRD \\
--project-id <PROJECT_ID>

# New version of existing document:
uv run --with 'mcp[cli]' scripts/upload_artifact.py \\
uv run --with 'mcp[cli]>=2,<3' scripts/upload_artifact.py \\
--file /path/to/content.md \\
--artifact-id <DOCUMENT_ID_OR_SLUG>

# Create + verify round-trip:
uv run --with 'mcp[cli]' scripts/upload_artifact.py \\
uv run --with 'mcp[cli]>=2,<3' scripts/upload_artifact.py \\
--file /path/to/content.md \\
--title "My PRD" \\
--type PRD \\
Expand All @@ -45,9 +45,11 @@
import json
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path

import httpx
import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

Expand All @@ -71,13 +73,25 @@ class _Args(argparse.Namespace):
_MCP_URL_ENV_VAR = "NEXT_PUBLIC_MCP_SERVER_URL"


def _build_http_client(api_key: str) -> httpx.AsyncClient:
return httpx.AsyncClient(
def _build_http_client(api_key: str) -> httpx2.AsyncClient:
return httpx2.AsyncClient(
headers={"Authorization": f"Bearer {api_key}"},
timeout=httpx.Timeout(120.0, read=300.0),
timeout=httpx2.Timeout(120.0, read=300.0),
)


@asynccontextmanager
async def _connect(args: _Args) -> AsyncIterator[ClientSession]:
"""Open an initialized MCP session over Streamable HTTP."""
async with _build_http_client(args.api_key) as http_client:
async with streamable_http_client(
args.url, http_client=http_client
) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session


def _extract_text(result) -> str:
"""Extract the first text content from an MCP tool result."""
for c in result.content or []:
Expand All @@ -88,26 +102,15 @@ def _extract_text(result) -> str:

async def list_projects(args: _Args) -> dict:
"""List all available projects."""
http_client = _build_http_client(args.api_key)
try:
async with http_client, streamable_http_client(
args.url, http_client=http_client
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.call_tool("list-projects", {})
if result.isError:
return {
"error": "list-projects failed",
"details": [
getattr(c, "text", str(c))
for c in (result.content or [])
],
}
text = _extract_text(result)
if not text:
return {"error": "Empty response from list-projects"}
return json.loads(text)
async with _connect(args) as session:
result = await session.call_tool("list-projects", {})
if result.is_error:
return {"error": "list-projects failed", **_error_details(result)}
text = _extract_text(result)
if not text:
return {"error": "Empty response from list-projects"}
return json.loads(text)
except (Exception, ExceptionGroup) as exc:
return _format_exception(exc)

Expand All @@ -126,7 +129,7 @@ async def _version_document(
"create-document-version",
{"documentId": document_id, "content": content},
)
if result.isError:
if result.is_error:
return {"error": "create-document-version failed", **_error_details(result)}
text = _extract_text(result)
parsed = json.loads(text) if text else {}
Expand Down Expand Up @@ -159,7 +162,7 @@ async def _create_document(
tool_args["workstreamId"] = args.workstream_id

result = await session.call_tool("create-document", tool_args)
if result.isError:
if result.is_error:
return {"error": "create-document failed", **_error_details(result)}
text = _extract_text(result)
if not text:
Expand Down Expand Up @@ -187,25 +190,19 @@ async def upload(args: _Args) -> dict:

content = file_path.read_text(encoding="utf-8")

http_client = _build_http_client(args.api_key)
try:
async with http_client, streamable_http_client(
args.url, http_client=http_client
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()

if args.artifact_id:
output = await _version_document(session, args.artifact_id, content)
else:
output = await _create_document(session, args, content)

if "error" not in output and args.verify:
output["verify"] = await _verify_document(
session, output["artifact_id"], len(content)
)
async with _connect(args) as session:
if args.artifact_id:
output = await _version_document(session, args.artifact_id, content)
else:
output = await _create_document(session, args, content)

if "error" not in output and args.verify:
output["verify"] = await _verify_document(
session, output["artifact_id"], len(content)
)

return output
return output
except (Exception, ExceptionGroup) as exc:
return _format_exception(exc)

Expand All @@ -223,13 +220,8 @@ async def _verify_document(
"contentMaxChars": fetch_max,
},
)
if result.isError:
return {
"error": "get-document failed",
"details": [
getattr(c, "text", str(c)) for c in (result.content or [])
],
}
if result.is_error:
return {"error": "get-document failed", **_error_details(result)}
text = _extract_text(result)
if not text:
return {"error": "Empty response from get-document"}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ extraPaths = ["plugins/code/skills/plan-validate/scripts"]
[dependency-groups]
dev = [
"anthropic==0.92.0",
"mcp==1.27.0",
"mcp==2.0.0",
"pyright==1.1.408",
"pytest==9.0.3",
"PyYAML==6.0.3",
Expand Down
Loading
Loading