Skip to content
Open
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
18 changes: 12 additions & 6 deletions .github/workflows/build-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Install Modern Docker CLI
run: |
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu jammy stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce-cli
- name: Plane MCP Server Build and Push
uses: makeplane/actions/build-push@v1.0.0
with:
Expand All @@ -102,7 +112,7 @@ jobs:
release-version: ${{ needs.release_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-owner: ${{ secrets.DOCKERHUB_USERNAME }}
docker-image-name: ${{ needs.release_build_setup.outputs.dh_img_name }}
build-context: .
dockerfile-path: ./Dockerfile
Expand All @@ -115,11 +125,7 @@ jobs:
if: ${{ needs.release_build_setup.outputs.build_type == 'Release' }}
name: Build Release
runs-on: ubuntu-22.04
needs:
[
release_build_setup,
build_and_push,
]
needs: [release_build_setup, build_and_push]
env:
REL_VERSION: ${{ needs.release_build_setup.outputs.release_version }}
steps:
Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: CI / CD

on:
push:
branches:
- main
- master
- ai-journeys-rearchitecture
- plane-agent
pull_request:
branches:
- main
- master
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

permissions:
contents: read

jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
container:
image: python:3.12
steps:
- name: Setup and Checkout
uses: actions/checkout@v4

- name: Run tests
run: |
set +e
pip install '.[dev]' pytest pytest-asyncio pytest-mock pytest-timeout respx requests-mock anyio
pytest -v > test-output.log 2>&1
TEST_EXIT=$?
cat test-output.log
exit $TEST_EXIT

build-and-push:
name: Build & Push Docker
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
permissions:
packages: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Set image tags
id: tags
run: |
BRANCH_TAG=$(echo "${{ github.ref_name }}" | sed 's|/|-|g')
IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/plane-mcp-server"
TAGS="${IMAGE}:${BRANCH_TAG}"
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.ref_name }}" = "master" ]; then
TAGS="${TAGS},${IMAGE}:latest"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.tags }}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ dmypy.json
.env.test.local

# Ignore cursor AI rules
.cursor/rules/codacy.mdc
.cursor/rules/codacy.mdc
31 changes: 31 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# This docker-compose configuration is intended for stand-alone MCP server testing.
services:
plane-mcp:
build: .
container_name: plane-mcp
restart: unless-stopped
ports:
- "${FASTMCP_PORT:-8211}:${FASTMCP_PORT:-8211}"
environment:
- PLANE_API_KEY=${PLANE_API_KEY}
- PLANE_WORKSPACE_SLUG=${PLANE_WORKSPACE_SLUG}
- PLANE_BASE_URL=${PLANE_BASE_URL}
- FASTMCP_PORT=${FASTMCP_PORT:-8211}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- REDIS_HOST=redis
- REDIS_PORT=6379
entrypoint: ["python", "-m", "plane_mcp"]
command: ["http"]
Comment thread
adamoutler marked this conversation as resolved.
depends_on:
- redis

redis:
image: redis:alpine
container_name: plane-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data

volumes:
redis_data:
30 changes: 30 additions & 0 deletions plane_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,31 @@
"""Plane MCP Server - A Model Context Protocol server for Plane integration."""

import os
from contextlib import asynccontextmanager
from fastmcp import FastMCP

_original_fastmcp_init = FastMCP.__init__

def _patched_fastmcp_init(self, *args, **kwargs):
if "tasks" not in kwargs:
kwargs["tasks"] = os.getenv("PLANE_ALLOW_MEMORY_TASKS", "false").lower() == "true"
_original_fastmcp_init(self, *args, **kwargs)
self.__mcp_patched_tasks_enabled = kwargs.get("tasks", False)

FastMCP.__init__ = _patched_fastmcp_init

_original_docket_lifespan = FastMCP._docket_lifespan

@asynccontextmanager
async def _patched_docket_lifespan(self):
tasks_enabled = getattr(self, "__mcp_patched_tasks_enabled", True)
if not tasks_enabled:
try:
yield
finally:
pass
return
async with _original_docket_lifespan(self):
yield

FastMCP._docket_lifespan = _patched_docket_lifespan
40 changes: 30 additions & 10 deletions plane_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
import os
import sys
from contextlib import asynccontextmanager
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timezone
from enum import Enum

Expand Down Expand Up @@ -61,13 +61,12 @@ class ServerMode(Enum):


@asynccontextmanager
async def combined_lifespan(oauth_app, header_app, sse_app):
"""Combine lifespans from both OAuth and Header MCP apps."""
# Start both lifespans
async with oauth_app.lifespan(oauth_app):
async with header_app.lifespan(header_app):
async with sse_app.lifespan(sse_app):
yield
async def combined_lifespan(apps):
"""Combine lifespans from multiple MCP apps."""
async with AsyncExitStack() as stack:
for app in apps:
await stack.enter_async_context(app.lifespan(app))
yield


def main() -> None:
Expand All @@ -83,7 +82,10 @@ def main() -> None:
if not os.getenv("PLANE_WORKSPACE_SLUG"):
raise ValueError("PLANE_WORKSPACE_SLUG is not set")

get_stdio_mcp().run()
from plane_mcp.journey.tools import register_tools as register_journey_tools
stdio_mcp = get_stdio_mcp()
register_journey_tools(stdio_mcp)
stdio_mcp.run()
return

if server_mode == ServerMode.HTTP:
Expand All @@ -97,17 +99,35 @@ def main() -> None:
oauth_well_known = oauth_mcp.auth.get_well_known_routes(mcp_path="/mcp")
sse_well_known = sse_mcp.auth.get_well_known_routes(mcp_path="/sse")

# --- AGENT JOURNEY API ---
from plane_mcp.journey.server import (
get_header_mcp as journey_get_header_mcp,
get_oauth_mcp as journey_get_oauth_mcp
)
journey_oauth_mcp = journey_get_oauth_mcp("/agent")
journey_oauth_app = journey_oauth_mcp.http_app(stateless_http=True)

journey_header_mcp = journey_get_header_mcp()
journey_header_app = journey_header_mcp.http_app(stateless_http=True)

journey_oauth_well_known = []
if hasattr(journey_oauth_mcp, 'auth') and journey_oauth_mcp.auth:
journey_oauth_well_known = journey_oauth_mcp.auth.get_well_known_routes(mcp_path="/agent/mcp")

app = Starlette(
routes=[
# Well-known routes for OAuth and Header HTTP
*oauth_well_known,
*sse_well_known,
*journey_oauth_well_known,
# Mount both MCP servers
Mount("/http/api-key", app=header_app),
Mount("/http", app=oauth_app),
Mount("/agent/api-key", app=journey_header_app),
Mount("/agent", app=journey_oauth_app),
Mount("/", app=sse_app),
],
lifespan=lambda app: combined_lifespan(oauth_app, header_app, sse_app),
lifespan=lambda app: combined_lifespan([oauth_app, header_app, sse_app, journey_oauth_app, journey_header_app]),
)

app.add_middleware(
Expand Down
Empty file added plane_mcp/journey/__init__.py
Empty file.
88 changes: 88 additions & 0 deletions plane_mcp/journey/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Main entry point for the Plane MCP Server."""

import os
import sys
from contextlib import asynccontextmanager
from enum import Enum

import uvicorn
from fastmcp.utilities.logging import get_logger
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount

from plane_mcp.journey.server import get_header_mcp, get_stdio_mcp

logger = get_logger(__name__)


class ServerMode(Enum):
STDIO = "stdio"
SSE = "sse"
HTTP = "http"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@asynccontextmanager
async def combined_lifespan(oauth_app, header_app, sse_app):
"""Combine lifespans from both OAuth and Header MCP apps."""
# Start both lifespans
async with oauth_app.lifespan(oauth_app):
async with header_app.lifespan(header_app):
async with sse_app.lifespan(sse_app):
yield


def main() -> None:
"""Run the MCP server."""
server_mode = ServerMode.STDIO
if len(sys.argv) > 1:
try:
server_mode = ServerMode(sys.argv[1])
except ValueError:
valid_modes = ", ".join(m.value for m in ServerMode)
raise ValueError(f"Invalid server mode '{sys.argv[1]}'. Valid modes: {valid_modes}") from None

if server_mode == ServerMode.STDIO:
# Validate API_KEY and PLANE_WORKSPACE_SLUG are set
if not os.getenv("PLANE_API_KEY"):
raise ValueError("PLANE_API_KEY is not set")
if not os.getenv("PLANE_WORKSPACE_SLUG"):
raise ValueError("PLANE_WORKSPACE_SLUG is not set")

get_stdio_mcp().run()
return

if server_mode == ServerMode.SSE:
raise NotImplementedError(
"SSE mode is not implemented in the agent server. "
"Use 'stdio' or 'http'. SSE transport is defined in the MCP spec "
"but not supported by this endpoint."
)

if server_mode == ServerMode.HTTP:
http_mcp = get_header_mcp()
http_app = http_mcp.http_app(transport="streamable-http")

app = Starlette(
routes=[
Mount("/", app=http_app),
],
lifespan=lambda app: http_app.lifespan(http_app),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

port = int(os.getenv("FASTMCP_PORT", "8211"))
logger.info(f"Starting HTTP server for Streamable HTTP at / on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
return


if __name__ == "__main__":
main()
Loading