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:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user