fix: Update confluence-collab proxy with proper async lifecycle (CF-1812)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-24 11:51:29 +02:00
parent b492abe0c9
commit 9958fb9b6b

View File

@@ -3,6 +3,8 @@
Spawns mcp-atlassian as a subprocess (stdio), proxies all its tools, and
registers the confluence_section_* tools from this package. Claude Code
sees a single MCP server with all tools under one prefix.
Usage: python -m confluence_collab.proxy
"""
from __future__ import annotations
@@ -13,81 +15,165 @@ import logging
import os
import sys
from mcp.server.fastmcp import FastMCP
from mcp import types
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from confluence_collab.server import (
confluence_section_list,
confluence_section_get,
confluence_section_update,
confluence_section_append,
confluence_section_delete,
from confluence_collab.client import Auth
from confluence_collab.editor import (
section_list,
section_get,
section_update,
section_append,
section_delete,
)
logger = logging.getLogger("confluence-collab-proxy")
mcp = FastMCP("atlassian-with-sections")
# Section tool definitions (added alongside proxied mcp-atlassian tools)
SECTION_TOOLS = [
types.Tool(
name="confluence_section_list",
description="List all sections (headings) on a Confluence page. Returns JSON array of {heading, level}.",
inputSchema={
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Confluence page ID"},
},
"required": ["page_id"],
},
),
types.Tool(
name="confluence_section_get",
description="Get the HTML content of a specific section by heading text. Uses fuzzy matching (case-insensitive, partial match).",
inputSchema={
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Confluence page ID"},
"heading": {"type": "string", "description": "Section heading text to find"},
},
"required": ["page_id", "heading"],
},
),
types.Tool(
name="confluence_section_update",
description="Update the content of a section identified by heading. Replaces only the target section, preserving the rest of the page. Handles version conflicts with automatic retry (exponential backoff).",
inputSchema={
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Confluence page ID"},
"heading": {"type": "string", "description": "Section heading text to find (fuzzy matched)"},
"body": {"type": "string", "description": "New HTML content for the section"},
},
"required": ["page_id", "heading", "body"],
},
),
types.Tool(
name="confluence_section_append",
description="Append HTML content to the end of a section. Adds content after existing section content, before the next heading.",
inputSchema={
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Confluence page ID"},
"heading": {"type": "string", "description": "Section heading text to find (fuzzy matched)"},
"body": {"type": "string", "description": "HTML content to append"},
},
"required": ["page_id", "heading", "body"],
},
),
types.Tool(
name="confluence_section_delete",
description="Delete an entire section (heading + content) from a page. Removes the heading tag and all content up to the next same-or-higher-level heading.",
inputSchema={
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Confluence page ID"},
"heading": {"type": "string", "description": "Section heading text to find (fuzzy matched)"},
},
"required": ["page_id", "heading"],
},
),
]
# Register section tools directly (they're already decorated with @mcp.tool in server.py,
# but we need to re-register them on this new FastMCP instance)
mcp.tool()(confluence_section_list)
mcp.tool()(confluence_section_get)
mcp.tool()(confluence_section_update)
mcp.tool()(confluence_section_append)
mcp.tool()(confluence_section_delete)
SECTION_TOOL_NAMES = {t.name for t in SECTION_TOOLS}
async def _proxy_upstream_tools() -> None:
"""Connect to mcp-atlassian subprocess and proxy its tools."""
def _get_auth() -> Auth:
return Auth.from_env()
async def _handle_section_tool(name: str, arguments: dict) -> list[types.TextContent]:
"""Handle a section tool call and return MCP content."""
auth = _get_auth()
page_id = arguments["page_id"]
if name == "confluence_section_list":
sections = await section_list(page_id, auth)
text = json.dumps([{"heading": s.heading, "level": s.level} for s in sections], indent=2)
elif name == "confluence_section_get":
content = await section_get(page_id, arguments["heading"], auth)
text = content if content is not None else f"Section '{arguments['heading']}' not found on page {page_id}"
elif name == "confluence_section_update":
result = await section_update(page_id, arguments["heading"], arguments["body"], auth)
text = json.dumps({"status": "ok" if result.ok else "error", "message": result.message, "version": result.version, "retries": result.retries})
elif name == "confluence_section_append":
result = await section_append(page_id, arguments["heading"], arguments["body"], auth)
text = json.dumps({"status": "ok" if result.ok else "error", "message": result.message, "version": result.version})
elif name == "confluence_section_delete":
result = await section_delete(page_id, arguments["heading"], auth)
text = json.dumps({"status": "ok" if result.ok else "error", "message": result.message, "version": result.version})
else:
text = f"Unknown section tool: {name}"
return [types.TextContent(type="text", text=text)]
async def run_proxy():
"""Run the composite MCP server with upstream proxy + section tools."""
server = Server("atlassian")
# Connect to upstream mcp-atlassian
cmd = "uvx"
args = ["--python", "3.13", "mcp-atlassian"]
server_params = StdioServerParameters(command=cmd, args=args, env=dict(os.environ))
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
async with stdio_client(server_params) as (upstream_read, upstream_write):
async with ClientSession(upstream_read, upstream_write) as upstream:
await upstream.initialize()
# List upstream tools
tools_result = await session.list_tools()
logger.info("Proxying %d upstream tools from mcp-atlassian", len(tools_result.tools))
# Discover upstream tools
upstream_tools_result = await upstream.list_tools()
upstream_tools = upstream_tools_result.tools
logger.info("Proxying %d upstream tools + %d section tools", len(upstream_tools), len(SECTION_TOOLS))
# Register each upstream tool as a proxy on our server
for tool in tools_result.tools:
_register_proxy_tool(tool, session)
# Combined tool list
all_tools = list(upstream_tools) + SECTION_TOOLS
# Keep running until interrupted
await asyncio.Event().wait()
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
return all_tools
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict | None) -> list[types.TextContent]:
arguments = arguments or {}
def _register_proxy_tool(tool, session: ClientSession) -> None:
"""Register a proxied tool from the upstream MCP server."""
# Section tools handled locally
if name in SECTION_TOOL_NAMES:
return await _handle_section_tool(name, arguments)
async def proxy_handler(**kwargs):
result = await session.call_tool(tool.name, kwargs)
# Return concatenated text content
texts = []
for content in result.content:
if hasattr(content, "text"):
texts.append(content.text)
return "\n".join(texts) if texts else ""
# Proxy to upstream mcp-atlassian
result = await upstream.call_tool(name, arguments)
return result.content
proxy_handler.__name__ = tool.name
proxy_handler.__doc__ = tool.description or ""
# Build parameter annotations from tool schema
mcp.tool(name=tool.name, description=tool.description or "")(proxy_handler)
# Run our server on stdio
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
def main():
"""Run the composite MCP server.
Note: The proxy approach requires running the upstream mcp-atlassian
as a subprocess. For simpler deployment, use the standalone server.py
which only provides section tools, and keep mcp-atlassian separate.
"""
mcp.run(transport="stdio")
asyncio.run(run_proxy())
if __name__ == "__main__":