diff --git a/poetry.lock b/poetry.lock index fa4ddce..686b21a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -636,7 +636,7 @@ idna = ">=2.0.0" name = "exceptiongroup" version = "1.3.1" description = "Backport of PEP 654 (exception groups)" -optional = false +optional = true python-versions = ">=3.7" groups = ["main", "dev"] files = [ @@ -653,40 +653,40 @@ test = ["pytest (>=6)"] [[package]] name = "fastmcp" -version = "3.4.7" +version = "4.0.3" description = "The fast, Pythonic way to build MCP servers and clients." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56"}, - {file = "fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a"}, + {file = "fastmcp-4.0.3-py3-none-any.whl", hash = "sha256:f743f43193e1daf606e6497a9dddba5bbaad7df7957a98fd093716ecf4458b99"}, + {file = "fastmcp-4.0.3.tar.gz", hash = "sha256:0dd50baf070105ee4436c245f087ac3c047e655c32d9e783de16d0bf15783a98"}, ] [package.dependencies] -fastmcp-slim = {version = "3.4.7", extras = ["client", "server"]} +fastmcp-slim = {version = "4.0.3", extras = ["client", "server"]} [package.extras] -anthropic = ["fastmcp-slim[anthropic] (==3.4.7)"] -apps = ["fastmcp-slim[apps] (==3.4.7)"] -azure = ["fastmcp-slim[azure] (==3.4.7)"] -code-mode = ["fastmcp-slim[code-mode] (==3.4.7)"] -gemini = ["fastmcp-slim[gemini] (==3.4.7)"] -openai = ["fastmcp-slim[openai] (==3.4.7)"] -tasks = ["fastmcp-slim[tasks] (==3.4.7)"] +anthropic = ["fastmcp-slim[anthropic] (==4.0.3)"] +apps = ["fastmcp-slim[apps] (==4.0.3)"] +azure = ["fastmcp-slim[azure] (==4.0.3)"] +code-mode = ["fastmcp-slim[code-mode] (==4.0.3)"] +gemini = ["fastmcp-slim[gemini] (==4.0.3)"] +openai = ["fastmcp-slim[openai] (==4.0.3)"] +tasks = ["fastmcp-tasks (==4.0.3)"] [[package]] name = "fastmcp-slim" -version = "3.4.7" +version = "4.0.3" description = "The dependency-slim FastMCP package." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a"}, - {file = "fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f"}, + {file = "fastmcp_slim-4.0.3-py3-none-any.whl", hash = "sha256:3756ed4bd9f82f40adaf51974b7cdd1463ab6b9f479cf69b69b2692969312b87"}, + {file = "fastmcp_slim-4.0.3.tar.gz", hash = "sha256:3ce04a82b82cc41ccb12bb3bb366cfd65b52bc797b8fee332a75da9d480f9b74"}, ] [package.dependencies] @@ -694,17 +694,18 @@ authlib = {version = ">=1.6.11", optional = true, markers = "extra == \"client\" cyclopts = {version = ">=4.0.0", optional = true, markers = "extra == \"server\""} exceptiongroup = {version = ">=1.2.2", optional = true, markers = "extra == \"client\" or extra == \"server\""} griffelib = {version = ">=2.0.0", optional = true, markers = "extra == \"server\""} -httpx = {version = ">=0.28.1,<1.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} -joserfc = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +httpx2 = {version = ">=2.5.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +joserfc = {version = ">=1.5.0", optional = true, markers = "extra == \"server\""} jsonref = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} jsonschema-path = {version = ">=0.3.4", optional = true, markers = "extra == \"server\""} -mcp = {version = ">=1.24.0,<2.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp = {version = ">=2.0.0,<3.0.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp-types = ">=2.0.0,<3.0.0" openapi-pydantic = {version = ">=0.5.1", optional = true, markers = "extra == \"server\""} -opentelemetry-api = {version = ">=1.20.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +opentelemetry-api = {version = ">=1.28.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} packaging = {version = ">=24.0", optional = true, markers = "extra == \"server\""} platformdirs = ">=4.0.0" py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"], optional = true, markers = "extra == \"client\" or extra == \"server\""} -pydantic = {version = ">=2.11.7", extras = ["email"]} +pydantic = {version = ">=2.12.0", extras = ["email"]} pydantic-settings = ">=2.0.0" pyperclip = {version = ">=1.9.0", optional = true, markers = "extra == \"server\""} python-dotenv = ">=1.1.0" @@ -713,22 +714,21 @@ pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"server\ rich = ">=13.9.4" starlette = {version = ">=1.0.1", optional = true, markers = "extra == \"client\" or extra == \"server\""} typing-extensions = ">=4.0.0" -uncalled-for = {version = ">=0.2.0", optional = true, markers = "extra == \"server\""} +uncalled-for = {version = ">=0.4.0", optional = true, markers = "extra == \"server\""} uvicorn = {version = ">=0.35", optional = true, markers = "extra == \"server\""} watchfiles = {version = ">=1.0.0", optional = true, markers = "extra == \"server\""} websockets = {version = ">=15.0.1", optional = true, markers = "extra == \"server\""} [package.extras] -anthropic = ["anthropic (>=0.48.0)"] +anthropic = ["anthropic (>=0.48.0,<1)"] apps = ["prefab-ui (>=0.18.0)"] azure = ["azure-identity (>=1.16.0)", "pyjwt (>=2.12.0)"] -client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] -code-mode = ["pydantic-monty (==0.0.17)"] +client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] +code-mode = ["pydantic-monty (==0.0.18)"] gemini = ["google-genai (>=1.18.0)", "jsonref (>=1.1.0)"] -mcp = ["exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "starlette (>=1.0.1)"] +mcp = ["exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "starlette (>=1.0.1)"] openai = ["openai (>=1.102.0)"] -server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx (>=0.28.1,<1.0)", "joserfc (>=1.1.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=1.24.0,<2.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.20.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.2.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] -tasks = ["pydocket (>=0.20.0)"] +server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx2 (>=2.5.0)", "joserfc (>=1.5.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=2.0.0,<3.0.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.28.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.4.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] [[package]] name = "filelock" @@ -772,65 +772,68 @@ files = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.12.0" description = "A minimal low-level HTTP client." optional = true -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] -markers = "extra == \"mcp\"" +markers = "extra == \"mcp\" and sys_platform != \"emscripten\"" files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, + {file = "httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb"}, + {file = "httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648"}, ] [package.dependencies] -certifi = "*" h11 = ">=0.16" +truststore = ">=0.10" [package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] +asyncio = ["anyio (>=4.5.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] +trio = ["trio (>=0.33.0,<1.0)"] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.12.0" description = "The next generation HTTP client." optional = true -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, + {file = "httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36"}, + {file = "httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf"}, ] [package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" +anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.12.0", markers = "sys_platform != \"emscripten\""} +httpx2-jsfetch = {version = "*", markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} +idna = ">=3.18" +truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.13\""} [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +cli = ["click (>=8.4.2)", "pygments (==2.*)", "rich (>=10,<16)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] +ws = ["wsproto (>=1.2)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version <= \"3.13\""] [[package]] -name = "httpx-sse" -version = "0.4.3" -description = "Consume Server-Sent Event (SSE) messages with HTTPX." +name = "httpx2-jsfetch" +version = "1.0" +description = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." optional = true -python-versions = ">=3.9" +python-versions = ">=3.12" groups = ["main"] -markers = "extra == \"mcp\"" +markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\" and extra == \"mcp\"" files = [ - {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, - {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, + {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, + {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, ] [[package]] @@ -850,18 +853,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.15" +version = "3.19" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, - {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, ] [package.extras] -all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] [[package]] name = "importlib-metadata" @@ -1134,46 +1137,59 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-tim [[package]] name = "mcp" -version = "1.28.1" +version = "2.2.0" description = "Model Context Protocol SDK" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df"}, - {file = "mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683"}, + {file = "mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81"}, + {file = "mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd"}, ] [package.dependencies] -anyio = ">=4.5" -httpx = ">=0.27.1,<1.0.0" -httpx-sse = ">=0.4" -jsonschema = ">=4.20.0" -pydantic = [ - {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""}, - {version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""}, +anyio = [ + {version = ">=4.9", markers = "python_version < \"3.14\""}, + {version = ">=4.10", markers = "python_version >= \"3.14\""}, ] -pydantic-settings = ">=2.5.2" +httpx2 = ">=2.5.0" +jsonschema = ">=4.20.0" +mcp-types = "2.2.0" +opentelemetry-api = ">=1.28.0" +pydantic = ">=2.12.0" pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = [ - {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""}, - {version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""}, -] -sse-starlette = ">=1.6.1" +pywin32 = {version = ">=311", markers = "sys_platform == \"win32\""} +sse-starlette = ">=3.0.0" starlette = [ {version = ">=0.27", markers = "python_version < \"3.14\""}, {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, ] -typing-extensions = ">=4.9.0" +typing-extensions = ">=4.13.0" typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] -ws = ["websockets (>=15.0.1)"] + +[[package]] +name = "mcp-types" +version = "2.2.0" +description = "Model Context Protocol wire types" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13"}, + {file = "mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad"}, +] + +[package.dependencies] +pydantic = ">=2.12.0" +typing-extensions = ">=4.13.0" [[package]] name = "mdit-py-plugins" @@ -1705,7 +1721,7 @@ description = "Python for Window Extensions" optional = true python-versions = "*" groups = ["main"] -markers = "sys_platform == \"win32\" and extra == \"mcp\"" +markers = "extra == \"mcp\" and sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -2149,11 +2165,24 @@ files = [ ] markers = {main = "extra == \"mcp\" and python_version == \"3.10\"", dev = "python_version == \"3.10\""} +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\" and sys_platform != \"emscripten\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false +optional = true python-versions = ">=3.9" groups = ["main", "dev"] files = [ @@ -2180,15 +2209,15 @@ typing-extensions = ">=4.12.0" [[package]] name = "uncalled-for" -version = "0.2.0" +version = "0.4.0" description = "Async dependency injection for Python functions" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f"}, - {file = "uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f"}, + {file = "uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd"}, + {file = "uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af"}, ] [[package]] @@ -2471,4 +2500,4 @@ mcp = ["fastmcp"] [metadata] lock-version = "2.1" python-versions = "<4.0,>=3.10" -content-hash = "08727f88910058315cae518dc73d00cf109a2e99fa5dee9dae911fe28c8238c4" +content-hash = "2aeef1f4de7f7d01cc248893fd5d4f00f461e40e4c8f7efa3d2aedef839d791b" diff --git a/pyproject.toml b/pyproject.toml index c31b52f..e14181e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ python-dotenv = "^1.2.1" PyYAML = "^6.0" markdown-it-py = ">=3,<5" mdit-py-plugins = ">=0.5,<0.7" -fastmcp = { version = "^3.1.1", optional = true } +fastmcp = { version = ">=3.1.1,<5.0.0", optional = true } [tool.poetry.extras] mcp = ["fastmcp"] diff --git a/substack_mcp/mcp_server.py b/substack_mcp/mcp_server.py index fe8b857..0a4061b 100644 --- a/substack_mcp/mcp_server.py +++ b/substack_mcp/mcp_server.py @@ -1,386 +1,386 @@ -from __future__ import annotations - -import os -from typing import Any, Dict, List, Optional - -try: - from dotenv import load_dotenv -except ImportError: - load_dotenv = None - -from mcp.server.fastmcp import FastMCP - -from substack.api import Api -from substack.post import Post - -if load_dotenv is not None: - load_dotenv() - - -def get_api() -> Api: - email = os.getenv("EMAIL") - password = os.getenv("PASSWORD") - cookies_path = os.getenv("COOKIES_PATH") - cookies_string = os.getenv("COOKIES_STRING") - publication_url = os.getenv("PUBLICATION_URL") - - if cookies_path or cookies_string: - return Api( - cookies_path=cookies_path, - cookies_string=cookies_string, - publication_url=publication_url, - ) - - if email and password: - return Api( - email=email, - password=password, - publication_url=publication_url, - ) - - raise ValueError( - "Missing Substack auth configuration: set EMAIL/PASSWORD or COOKIES_PATH/COOKIES_STRING" - ) - - -def _normalize_tags(tags: Optional[Any]) -> List[str]: - if tags is None: - return [] - if isinstance(tags, str): - return [tags] - if isinstance(tags, list): - return [str(tag) for tag in tags] - raise ValueError("tags must be a string or a list of strings") - - -mcp = FastMCP("substack") - - -@mcp.tool() -async def post_draft_from_markdown( - title: str, - markdown: str, - subtitle: Optional[str] = "", - audience: str = "everyone", - write_comment_permissions: str = "everyone", - search_engine_title: Optional[str] = None, - search_engine_description: Optional[str] = None, - slug: Optional[str] = None, - draft_section_id: Optional[int] = None, - tags: Optional[Any] = None, - prepublish: bool = False, - publish: bool = False, - send: bool = True, - share_automatically: bool = False, -) -> Dict[str, Any]: - """Create or update a Substack draft from Markdown. - - This tool builds a Substack `Post` from markdown content and posts a draft. - It supports optional tag assignment, prepublish (setup check), and publishing. - - Args: - title: Draft title. - markdown: Markdown body content. - subtitle: Optional subtitle text. - audience: One of `everyone`, `only_paid`, `founding`, `only_free`. - write_comment_permissions: One of `none`, `only_paid`, `everyone`. - search_engine_title: Optional title for search engine optimization. - search_engine_description: Optional description for search engine optimization. - slug: Optional URL slug for the post. - draft_section_id: Optional section ID for the draft. - tags: Tag or list of tags to attach to the post. - prepublish: If true, calls `prepublish_draft` after creation. - publish: If true, calls `publish_draft` after creation (and optionally prepublish). - send: Passed to `publish_draft` for newsletter delivery. - share_automatically: Passed to `publish_draft`. - - Returns: - dict containing drafted post (`draft`), optional `tags`, `prepublish`, `publish` results. - - Examples: - With the YAML structure from the README, a caller can map fields like: - - ```yaml - title: "My Post Title" - subtitle: "My Post Subtitle" - audience: "everyone" - write_comment_permissions: "everyone" - markdown: | - # Hello - - This is the body. - - tags: - - python - - substack - prepublish: true - publish: true - send: false - share_automatically: true - ``` - - Then invoke via MCP directly: - - ```python - from substack_mcp.mcp_server import post_draft_from_markdown - - result = await post_draft_from_markdown( - title='My Post Title', - markdown='# Hello\n\nThis is the body.', - subtitle='My Post Subtitle', - audience='everyone', - write_comment_permissions='everyone', - tags=['python', 'substack'], - prepublish=True, - publish=False, # set true when ready - ) - print(result) - ``` - - A longer process with manual prepublish/publish calls: - - ```python - from substack_mcp.mcp_server import ( - post_draft_from_markdown, - prepublish_draft, - publish_draft, - add_tags, - ) - - d = await post_draft_from_markdown( - title='Long flow', - markdown='Content', - tags=['a','b'], - publish=False, - ) - draft_id = d['draft']['id'] - - await add_tags(draft_id, ['post-tag', 'news']) - await prepublish_draft(draft_id) - await publish_draft(draft_id, send=True, share_automatically=True) - ``` - - This docstring example is meant to mirror the YAML-driven workflow and show how to decompose the same operations into explicit tool calls. - """ - client = get_api() - - return client.create_draft_from_markdown( - title=title, - markdown=markdown, - subtitle=subtitle, - audience=audience, - write_comment_permissions=write_comment_permissions, - search_engine_title=search_engine_title, - search_engine_description=search_engine_description, - slug=slug, - draft_section_id=draft_section_id, - tags=tags, - prepublish=prepublish, - publish=publish, - send=send, - share_automatically=share_automatically, - ) - - -@mcp.tool() -async def put_draft( - draft_id: int, - update_payload: Dict[str, Any], -) -> Dict[str, Any]: - """Update an existing draft by draft ID. - - Args: - draft_id: target draft identifier. - update_payload: dict of fields supported by Substack `put_draft` (e.g. `slug`, `draft_section_id`). - - Returns: - API response dict for the updated draft. - """ - client = get_api() - return client.put_draft(draft_id, **update_payload) - - -@mcp.tool() -async def add_tags(draft_id: int, tags: Any) -> Dict[str, Any]: - """Add tags to a specific draft/post. - - Args: - draft_id: target draft identifier. - tags: string or list of tag names (e.g. `"tech"` or `["tech", "python"]`). - - Returns: - Response from `add_tags_to_post` (tag IDs + names). - """ - client = get_api() - tags_list = _normalize_tags(tags) - if not tags_list: - raise ValueError("tags is required and cannot be empty") - return client.add_tags_to_post(draft_id, tags_list) - - -@mcp.tool() -async def prepublish_draft(draft_id: int) -> Dict[str, Any]: - """Invoke prepublish checks for a draft. - - Args: - draft_id: target draft identifier. - - Returns: - Prepublish response dict from Substack API. - """ - client = get_api() - return client.prepublish_draft(draft_id) - - -@mcp.tool() -async def publish_draft( - draft_id: int, - send: bool = True, - share_automatically: bool = False, -) -> Dict[str, Any]: - """Publish a draft to live post state. (Legacy compatibility interface). - - This tool remains for backward compatibility. It is recommended to use - `publish_draft_checked` instead, which provides a safer publishing path - with explicit confirmation and prepublish validation. - - Args: - draft_id: target draft identifier. - send: if False then do not send email to subscribers. - share_automatically: whether to auto-share (e.g. social propagation). - - Returns: - Response from Substack `publish_draft`. - """ - client = get_api() - return client.publish_draft( - draft_id, send=send, share_automatically=share_automatically - ) - - -@mcp.tool() -async def publish_draft_checked( - draft_id: int, - confirm: bool = False, - send: bool = False, - share_automatically: bool = False, -) -> Dict[str, Any]: - """A safer publishing path that requires confirmation and runs prepublish checks. - - Args: - draft_id: target draft identifier. - confirm: Must be True to proceed with publication. - send: if False then do not send email to subscribers. Defaults to False. - share_automatically: whether to auto-share. - - Returns: - Response from Substack `publish_draft`. - """ - if not confirm: - raise ValueError("Publishing rejected: confirm parameter must be True.") - - client = get_api() - client.prepublish_draft(draft_id) - return client.publish_draft( - draft_id, send=send, share_automatically=share_automatically - ) - - -@mcp.tool() -async def get_status() -> Dict[str, Any]: - """Get the authentication status and basic user information. - - Returns: - A dictionary containing user profile and primary publication details. - """ - client = get_api() - profile = client.get_user_profile() - primary_pub = client.get_user_primary_publication() - return {"profile": profile, "primary_publication": primary_pub} - - -@mcp.tool() -async def list_publications() -> List[Dict[str, Any]]: - """List all publications available to the authenticated user. - - Returns: - A list of publications. - """ - client = get_api() - return client.get_user_publications() - - -@mcp.tool() -async def list_drafts( - filter: str = "draft", offset: int = 0, limit: int = 25 -) -> List[Dict[str, Any]]: - """List drafts for the current publication. - - Args: - filter: Filter string, defaults to "draft". - offset: Pagination offset. - limit: Max number of drafts to return. - - Returns: - A list of drafts. - """ - client = get_api() - return client.get_drafts(filter=filter, offset=offset, limit=limit) - - -@mcp.tool() -async def get_draft(draft_id: int) -> Dict[str, Any]: - """Get a specific draft by its ID. - - Args: - draft_id: The identifier of the draft. - - Returns: - The draft details. - """ - client = get_api() - return client.get_draft(draft_id) - - -@mcp.tool() -async def schedule_draft(draft_id: int, at: str) -> Dict[str, Any]: - """Schedule a draft for release. - - Args: - draft_id: target draft identifier. - at: ISO 8601 formatted datetime string (e.g., "2024-01-01T12:00:00Z"). - - Returns: - API response dict for the scheduled draft. - """ - from datetime import datetime - - try: - draft_datetime = datetime.fromisoformat(at.replace("Z", "+00:00")) - except ValueError as e: - raise ValueError(f"Invalid ISO datetime string for 'at': {e}") - - client = get_api() - return client.schedule_draft(draft_id, draft_datetime) - - -@mcp.tool() -async def unschedule_draft(draft_id: int) -> Dict[str, Any]: - """Unschedule a previously scheduled draft. - - Args: - draft_id: target draft identifier. - - Returns: - API response dict for unscheduling. - """ - client = get_api() - return client.unschedule_draft(draft_id) - - -def main() -> None: - mcp.run(transport="stdio") - - -if __name__ == "__main__": - main() +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None + +from fastmcp import FastMCP + +from substack.api import Api +from substack.post import Post + +if load_dotenv is not None: + load_dotenv() + + +def get_api() -> Api: + email = os.getenv("EMAIL") + password = os.getenv("PASSWORD") + cookies_path = os.getenv("COOKIES_PATH") + cookies_string = os.getenv("COOKIES_STRING") + publication_url = os.getenv("PUBLICATION_URL") + + if cookies_path or cookies_string: + return Api( + cookies_path=cookies_path, + cookies_string=cookies_string, + publication_url=publication_url, + ) + + if email and password: + return Api( + email=email, + password=password, + publication_url=publication_url, + ) + + raise ValueError( + "Missing Substack auth configuration: set EMAIL/PASSWORD or COOKIES_PATH/COOKIES_STRING" + ) + + +def _normalize_tags(tags: Optional[Any]) -> List[str]: + if tags is None: + return [] + if isinstance(tags, str): + return [tags] + if isinstance(tags, list): + return [str(tag) for tag in tags] + raise ValueError("tags must be a string or a list of strings") + + +mcp = FastMCP("substack") + + +@mcp.tool() +async def post_draft_from_markdown( + title: str, + markdown: str, + subtitle: Optional[str] = "", + audience: str = "everyone", + write_comment_permissions: str = "everyone", + search_engine_title: Optional[str] = None, + search_engine_description: Optional[str] = None, + slug: Optional[str] = None, + draft_section_id: Optional[int] = None, + tags: Optional[Any] = None, + prepublish: bool = False, + publish: bool = False, + send: bool = True, + share_automatically: bool = False, +) -> Dict[str, Any]: + """Create or update a Substack draft from Markdown. + + This tool builds a Substack `Post` from markdown content and posts a draft. + It supports optional tag assignment, prepublish (setup check), and publishing. + + Args: + title: Draft title. + markdown: Markdown body content. + subtitle: Optional subtitle text. + audience: One of `everyone`, `only_paid`, `founding`, `only_free`. + write_comment_permissions: One of `none`, `only_paid`, `everyone`. + search_engine_title: Optional title for search engine optimization. + search_engine_description: Optional description for search engine optimization. + slug: Optional URL slug for the post. + draft_section_id: Optional section ID for the draft. + tags: Tag or list of tags to attach to the post. + prepublish: If true, calls `prepublish_draft` after creation. + publish: If true, calls `publish_draft` after creation (and optionally prepublish). + send: Passed to `publish_draft` for newsletter delivery. + share_automatically: Passed to `publish_draft`. + + Returns: + dict containing drafted post (`draft`), optional `tags`, `prepublish`, `publish` results. + + Examples: + With the YAML structure from the README, a caller can map fields like: + + ```yaml + title: "My Post Title" + subtitle: "My Post Subtitle" + audience: "everyone" + write_comment_permissions: "everyone" + markdown: | + # Hello + + This is the body. + + tags: + - python + - substack + prepublish: true + publish: true + send: false + share_automatically: true + ``` + + Then invoke via MCP directly: + + ```python + from substack_mcp.mcp_server import post_draft_from_markdown + + result = await post_draft_from_markdown( + title='My Post Title', + markdown='# Hello\n\nThis is the body.', + subtitle='My Post Subtitle', + audience='everyone', + write_comment_permissions='everyone', + tags=['python', 'substack'], + prepublish=True, + publish=False, # set true when ready + ) + print(result) + ``` + + A longer process with manual prepublish/publish calls: + + ```python + from substack_mcp.mcp_server import ( + post_draft_from_markdown, + prepublish_draft, + publish_draft, + add_tags, + ) + + d = await post_draft_from_markdown( + title='Long flow', + markdown='Content', + tags=['a','b'], + publish=False, + ) + draft_id = d['draft']['id'] + + await add_tags(draft_id, ['post-tag', 'news']) + await prepublish_draft(draft_id) + await publish_draft(draft_id, send=True, share_automatically=True) + ``` + + This docstring example is meant to mirror the YAML-driven workflow and show how to decompose the same operations into explicit tool calls. + """ + client = get_api() + + return client.create_draft_from_markdown( + title=title, + markdown=markdown, + subtitle=subtitle, + audience=audience, + write_comment_permissions=write_comment_permissions, + search_engine_title=search_engine_title, + search_engine_description=search_engine_description, + slug=slug, + draft_section_id=draft_section_id, + tags=tags, + prepublish=prepublish, + publish=publish, + send=send, + share_automatically=share_automatically, + ) + + +@mcp.tool() +async def put_draft( + draft_id: int, + update_payload: Dict[str, Any], +) -> Dict[str, Any]: + """Update an existing draft by draft ID. + + Args: + draft_id: target draft identifier. + update_payload: dict of fields supported by Substack `put_draft` (e.g. `slug`, `draft_section_id`). + + Returns: + API response dict for the updated draft. + """ + client = get_api() + return client.put_draft(draft_id, **update_payload) + + +@mcp.tool() +async def add_tags(draft_id: int, tags: Any) -> Dict[str, Any]: + """Add tags to a specific draft/post. + + Args: + draft_id: target draft identifier. + tags: string or list of tag names (e.g. `"tech"` or `["tech", "python"]`). + + Returns: + Response from `add_tags_to_post` (tag IDs + names). + """ + client = get_api() + tags_list = _normalize_tags(tags) + if not tags_list: + raise ValueError("tags is required and cannot be empty") + return client.add_tags_to_post(draft_id, tags_list) + + +@mcp.tool() +async def prepublish_draft(draft_id: int) -> Dict[str, Any]: + """Invoke prepublish checks for a draft. + + Args: + draft_id: target draft identifier. + + Returns: + Prepublish response dict from Substack API. + """ + client = get_api() + return client.prepublish_draft(draft_id) + + +@mcp.tool() +async def publish_draft( + draft_id: int, + send: bool = True, + share_automatically: bool = False, +) -> Dict[str, Any]: + """Publish a draft to live post state. (Legacy compatibility interface). + + This tool remains for backward compatibility. It is recommended to use + `publish_draft_checked` instead, which provides a safer publishing path + with explicit confirmation and prepublish validation. + + Args: + draft_id: target draft identifier. + send: if False then do not send email to subscribers. + share_automatically: whether to auto-share (e.g. social propagation). + + Returns: + Response from Substack `publish_draft`. + """ + client = get_api() + return client.publish_draft( + draft_id, send=send, share_automatically=share_automatically + ) + + +@mcp.tool() +async def publish_draft_checked( + draft_id: int, + confirm: bool = False, + send: bool = False, + share_automatically: bool = False, +) -> Dict[str, Any]: + """A safer publishing path that requires confirmation and runs prepublish checks. + + Args: + draft_id: target draft identifier. + confirm: Must be True to proceed with publication. + send: if False then do not send email to subscribers. Defaults to False. + share_automatically: whether to auto-share. + + Returns: + Response from Substack `publish_draft`. + """ + if not confirm: + raise ValueError("Publishing rejected: confirm parameter must be True.") + + client = get_api() + client.prepublish_draft(draft_id) + return client.publish_draft( + draft_id, send=send, share_automatically=share_automatically + ) + + +@mcp.tool() +async def get_status() -> Dict[str, Any]: + """Get the authentication status and basic user information. + + Returns: + A dictionary containing user profile and primary publication details. + """ + client = get_api() + profile = client.get_user_profile() + primary_pub = client.get_user_primary_publication() + return {"profile": profile, "primary_publication": primary_pub} + + +@mcp.tool() +async def list_publications() -> List[Dict[str, Any]]: + """List all publications available to the authenticated user. + + Returns: + A list of publications. + """ + client = get_api() + return client.get_user_publications() + + +@mcp.tool() +async def list_drafts( + filter: str = "draft", offset: int = 0, limit: int = 25 +) -> List[Dict[str, Any]]: + """List drafts for the current publication. + + Args: + filter: Filter string, defaults to "draft". + offset: Pagination offset. + limit: Max number of drafts to return. + + Returns: + A list of drafts. + """ + client = get_api() + return client.get_drafts(filter=filter, offset=offset, limit=limit) + + +@mcp.tool() +async def get_draft(draft_id: int) -> Dict[str, Any]: + """Get a specific draft by its ID. + + Args: + draft_id: The identifier of the draft. + + Returns: + The draft details. + """ + client = get_api() + return client.get_draft(draft_id) + + +@mcp.tool() +async def schedule_draft(draft_id: int, at: str) -> Dict[str, Any]: + """Schedule a draft for release. + + Args: + draft_id: target draft identifier. + at: ISO 8601 formatted datetime string (e.g., "2024-01-01T12:00:00Z"). + + Returns: + API response dict for the scheduled draft. + """ + from datetime import datetime + + try: + draft_datetime = datetime.fromisoformat(at.replace("Z", "+00:00")) + except ValueError as e: + raise ValueError(f"Invalid ISO datetime string for 'at': {e}") + + client = get_api() + return client.schedule_draft(draft_id, draft_datetime) + + +@mcp.tool() +async def unschedule_draft(draft_id: int) -> Dict[str, Any]: + """Unschedule a previously scheduled draft. + + Args: + draft_id: target draft identifier. + + Returns: + API response dict for unscheduling. + """ + client = get_api() + return client.unschedule_draft(draft_id) + + +def main() -> None: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main()