Compare commits

...
2 Commits
Author SHA1 Message Date
John Lancaster f5b65ecf0a test changes 2026-08-29 22:28:55 -05:00
John Lancaster c31a78206f debug launch config 2026-08-29 21:06:02 -05:00
13 changed files with 186 additions and 49 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: personal-mcp entrypoint",
"type": "debugpy",
"request": "launch",
"module": "personal_mcp.__main__",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"PYTHONUNBUFFERED": "1"
},
"args": [ "--port", "8766", "--reload" ]
}
]
}
+1 -7
View File
@@ -51,13 +51,7 @@
"command": "uv", "command": "uv",
"args": [ "args": [
"run", "run",
"uvicorn", "personal-mcp",
"personal_mcp.main:create_app",
"--factory",
"--host",
"127.0.0.1",
"--port",
"8000",
"--reload" "--reload"
], ],
"options": { "options": {
+25 -30
View File
@@ -1,57 +1,52 @@
FROM python:3.14-slim AS builder FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONUNBUFFERED=1 \ UV_SYSTEM_CERTS=1 \
UV_PYTHON_DOWNLOADS=0 \
UV_NO_MANAGED_PYTHON=1 \
UV_SYSTEM_PYTHON=1 \
UV_PROJECT_ENVIRONMENT=/usr/local \
UV_COMPILE_BYTECODE=1 \ UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_NO_DEV=1 \
UV_LOCKED=1 UV_LOCKED=1 \
UV_LINK_MODE=copy
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=zensical.toml,target=zensical.toml \ --mount=type=bind,source=zensical.toml,target=zensical.toml \
--mount=type=bind,source=docs/,target=docs/ \ --mount=type=bind,source=src/personal_mcp/docs/,target=src/personal_mcp/docs/ \
uvx zensical build uvx zensical build --clean --strict
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --no-install-project uv sync --no-install-project
# COPY --chown=appuser:appuser . /app COPY pyproject.toml uv.lock README.md /app/
COPY ./src /app/src
# RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
# uv sync --no-editable uv sync
FROM python:3.14-slim AS runtime FROM python:3.14-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1
PATH="/app/.venv/bin:$PATH" \
PERSONAL_MCP_HOST=0.0.0.0 \
PERSONAL_MCP_PORT=8765 \
PERSONAL_MCP_RELOAD=false \
PERSONAL_MCP_SITE_DIR=/app/site
WORKDIR /app
EXPOSE 8765
RUN groupadd --system --gid 1001 appuser && \ RUN groupadd --system --gid 1001 appuser && \
useradd --system --uid 1001 --gid appuser appuser useradd --system --uid 1001 --gid appuser appuser
COPY --from=ghcr.io/astral-sh/uv:latest --chown=appuser:appuser /uv /uvx /bin/ WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app/site /app/site
COPY --chown=appuser:appuser ./docs /app/docs
RUN --mount=type=cache,target=/root/.cache/uv \ RUN chown appuser:appuser /app
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ COPY --from=builder --chown=appuser:appuser /usr/local/lib /usr/local/lib
--mount=type=bind,source=src/,target=src/ \ COPY --from=builder --chown=appuser:appuser /usr/local/bin /usr/local/bin
uv sync --no-editable --refresh-package prompts COPY --from=builder --chown=appuser:appuser /app /app
USER appuser USER appuser
ENTRYPOINT ["python", "-m", "personal_mcp"] ENTRYPOINT ["/usr/local/bin/personal-mcp"]
+16
View File
@@ -0,0 +1,16 @@
# JSL MCP
```shell
uv run mcp-stdio
```
```shell
uv run fastmcp list --command "uv run mcp-stdio" --prompts
```
```shell
uv run fastmcp call --command "uv run mcp-stdio" \
--target authoring --prompt \
--input-json '{"artifact_type":"prompt","artifact_id":"release-notes","goal":"Create a reusable release-notes workflow."}' \
--json
```
+9 -4
View File
@@ -1,8 +1,13 @@
services: services:
personal-mcp: app:
build: image: personal-mcp:latest
context: . build: .
dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8765:8765" - "8765:8765"
environment:
PERSONAL_MCP_PORT: 8765
PERSONAL_MCP_HOST: 0.0.0.0
PERSONAL_MCP_RELOAD: 1
volumes:
- ./src:/app/src:ro
+6 -6
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "prompts" name = "personal_mcp"
version = "2.0.0" version = "2.0.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -17,13 +17,11 @@ constraint-dependencies = ["fastmcp-slim==4.0.0b4"]
[project.scripts] [project.scripts]
personal-mcp = "personal_mcp.__main__:main" personal-mcp = "personal_mcp.__main__:main"
mcp-stdio = "personal_mcp.mcp:run_stdio"
[build-system] [build-system]
requires = ["hatchling"] requires = ["uv_build>=0.12.7,<0.13"]
build-backend = "hatchling.build" build-backend = "uv_build"
[tool.hatch.build.targets.wheel]
packages = ["src/personal_mcp"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -33,6 +31,7 @@ dev = [
"ty>=0.0.51", "ty>=0.0.51",
] ]
test = [ test = [
"asgi-lifespan>=2.1.0",
"httpx2>=2.9.1", "httpx2>=2.9.1",
"pytest>=9.1.1", "pytest>=9.1.1",
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
@@ -52,3 +51,4 @@ markers = [
[tool.ty.src] [tool.ty.src]
include = ["src", "tests"] include = ["src", "tests"]
exclude = ["src/personal_mcp/docs/skills/*/examples/*.py"]
+1 -1
View File
@@ -7,7 +7,7 @@ def main(cli: bool = True) -> None:
"""Run the root MCP server.""" """Run the root MCP server."""
settings = get_settings(cli=cli) settings = get_settings(cli=cli)
uvicorn.run( uvicorn.run(
"personal_mcp.web.app:create_app", "personal_mcp.app:create_app",
factory=True, factory=True,
host=settings.host, host=settings.host,
port=settings.port, port=settings.port,
+18 -1
View File
@@ -8,6 +8,7 @@ from fastapi import Response
from fastapi import status from fastapi import status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastmcp.utilities.lifespan import combine_lifespans
from .config import Settings from .config import Settings
from .config import get_settings from .config import get_settings
@@ -16,7 +17,9 @@ from .mcp import create_mcp
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
runtime_settings = settings if settings is not None else get_settings() runtime_settings = settings if settings is not None else get_settings()
docs_route = runtime_settings.mounts.docs.rstrip("/") or "/docs"
mcp_app = create_mcp().http_app( mcp_app = create_mcp().http_app(
path="/",
json_response=True, json_response=True,
stateless_http=True, stateless_http=True,
transport="http", transport="http",
@@ -26,9 +29,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
openapi_url=None, openapi_url=None,
lifespan=app_lifespan, lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan),
) )
app.state.settings = runtime_settings app.state.settings = runtime_settings
async def redirect_root_to_docs() -> RedirectResponse:
return RedirectResponse(
url=docs_route,
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
app.add_api_route(
"/",
redirect_root_to_docs,
methods=["GET", "HEAD"],
include_in_schema=False,
)
app.mount(runtime_settings.mounts.mcp, mcp_app, name="mcp") app.mount(runtime_settings.mounts.mcp, mcp_app, name="mcp")
return app return app
+18
View File
@@ -0,0 +1,18 @@
from collections.abc import AsyncIterator
import pytest
from asgi_lifespan import LifespanManager
from fastmcp.utilities.tests import ASGIServer
from personal_mcp.app import create_app
@pytest.fixture
async def http_server() -> AsyncIterator[ASGIServer]:
app = create_app()
async with LifespanManager(app):
yield ASGIServer(
url="http://127.0.0.1/mcp",
app=app,
transport_type="http",
)
+22
View File
@@ -0,0 +1,22 @@
from fastmcp import Client
from mcp_types import TextResourceContents
async def assert_server_contract(client: Client) -> None:
resources = {str(resource.uri) for resource in await client.list_resources()}
templates = {str(template.uri_template) for template in await client.list_resource_templates()}
prompts = {prompt.name for prompt in await client.list_prompts()}
assert await client.list_tools() == []
assert "skill://pytesting/SKILL.md" in resources
assert "resource://docs/{path*}" in templates
assert "skill://pytesting/{path*}" in templates
assert "pytest-fill-scaffold" in prompts
skill_content = await client.read_resource("skill://pytesting/SKILL.md")
docs_content = await client.read_resource("resource://docs/index.md")
assert isinstance(skill_content[0], TextResourceContents)
assert "# Pytesting" in skill_content[0].text
assert isinstance(docs_content[0], TextResourceContents)
assert '"format": "markdown"' in docs_content[0].text
+9
View File
@@ -0,0 +1,9 @@
import pytest
from fastmcp.utilities.tests import ASGIServer
from server_contract import assert_server_contract
@pytest.mark.integration
async def test_fastapi_server(http_server: ASGIServer) -> None:
async with http_server.client() as client:
await assert_server_contract(client)
+20
View File
@@ -0,0 +1,20 @@
import sys
from pathlib import Path
import pytest
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
from server_contract import assert_server_contract
@pytest.mark.integration
async def test_stdio_server() -> None:
transport = StdioTransport(
command=sys.executable,
args=["-m", "personal_mcp.mcp"],
cwd=str(Path(__file__).parent.parent),
keep_alive=False,
)
async with Client(transport) as client:
await assert_server_contract(client)
Generated
+23
View File
@@ -52,6 +52,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "asgi-lifespan"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" },
]
[[package]] [[package]]
name = "asttokens" name = "asttokens"
version = "3.0.2" version = "3.0.2"
@@ -1126,6 +1138,7 @@ dev = [
{ name = "ty" }, { name = "ty" },
] ]
test = [ test = [
{ name = "asgi-lifespan" },
{ name = "httpx2" }, { name = "httpx2" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
@@ -1152,6 +1165,7 @@ dev = [
{ name = "ty", specifier = ">=0.0.51" }, { name = "ty", specifier = ">=0.0.51" },
] ]
test = [ test = [
{ name = "asgi-lifespan", specifier = ">=2.1.0" },
{ name = "httpx2", specifier = ">=2.9.1" }, { name = "httpx2", specifier = ">=2.9.1" },
{ name = "pytest", specifier = ">=9.1.1" }, { name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-asyncio", specifier = ">=1.4.0" },
@@ -1781,6 +1795,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.4.8" version = "3.4.8"