The MCP Hack That Gave Q CLI Vision — Part 1
Building the MCP Server
The MCP Hack That Gave Q CLI Vision — Part 1
Building the MCP Server

Picture this: you’re debugging a UI issue and need to explain it to Q CLI. You find yourself typing paragraph after paragraph about button alignment, color mismatches, and spacing issues. Despite your detailed descriptions, Q keeps missing the mark — suggesting fixes that don’t address what you’re actually seeing.
Then you try something different. You grab a screenshot, reference it in your prompt, and suddenly everything clicks. Q immediately understands the issue and provides the exact solution you need.
That moment of clarity? That’s what we’re building today.
In this two-part series, we’re building something that will fundamentally change how you interact with Q CLI — the ability to capture and share visual context through screenshots. Part 1 covers building the MCP server that handles screenshot capture. Part 2 shows you how to connect it to Q CLI and start using your shiny new tool.

Setting Up Your Development Environment
Let’s get your development environment ready for building our MCP server.
Install the required dependencies
First, make sure you’ve got **pipx, installed. From there, we’ll grab our package and project manager — [uv](https://docs.astral.sh/uv/):**
pipx install uv
Next, create a new directory for your project and set up a virtual environment:
mkdir vision-mcp-server
cd vision-mcp-server
uv init vision_mcp_server # This creates a vision_mcp_server subdirectory
cd vision_mcp_server
Now we need to install the project dependencies:
uv add fastmcp pytest-playwright
Since our tool will take screenshots, we also need to install Playwright’s browser binaries:
uv run -- playwright install
Structuring the Project
The project will contain the MCP server and agent in a single package that will be consumed by Q CLI.
Let’s organize the project and move the server logic into the src directory to support further development and enhancements.
mkdir -p src/vision_mcp_server
mv main.py src/vision_mcp_server/server.py
Your directory tree will now look like this:
.
├── pyproject.toml
├── README.md
├── src
│ └── vision_mcp_server
│ └── server.py
└── uv.lock
Grab vim or your favorite text editor and update the pyproject.toml configuration file to define the build system, target, and entrypoint:
# ./pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[project.scripts]
vision-mcp-server = "src.vision_mcp_server.server:main"
While we’re in pyproject.toml let’s also take the time to update the description of our project by replacing the default description with one a bit more descriptive such as:
The Vision MCP server provides specialized tools for analyzing webpages and taking screenshots through a headless browser.
Creating the MCP Server
Open server.py and add the following to top of the file:
# ./src/vision_mcp_server/server.py
from fastmcp import FastMCP
mcp = FastMCP(
"vision-mcp-server",
instructions="The Vision MCP server provides specialized tools for analyzing webpages through a headless browser. Use these tools when you need to take screenshots, analyze live webpages for debugging, or view different breakpoints."
)
In the same file, update the main function to run the MCP server:
def main():
mcp.run()
At this point, you’ve got an instance of FastMCP using the default STDIO transport.
Creating the Tool
Let’s create a class for our vision tool in vision.py.
touch src/vision_mcp_server/vision.py
We’ll start by defining the imports:
import os
import time
from playwright.async_api import async_playwright
from typing import Annotated
The built-ins os and time are used for constructing our dynamic filename and resource path. We’ll leverage async_playwright for our browser capability and Annotated to give the model context of our tool’s usage.
Next we’ll design the browser_screenshot function signature and give it a description:
async def browser_screenshot(
self,
url: Annotated[str, "The URL of the webpage to screenshot."],
viewport_width: Annotated[int, "The width of the browser window when taking a screenshot."] = None,
viewport_height: Annotated[int, "The height of the browser window when taking a screenshot."] = None,
full_page: Annotated[bool, "A flag to determine whether or not to capture the full height of the webpage."] = False,
scroll_offset: Annotated[int, "The number of pixels to scroll down, from the top of the page, before taking a screenshot."] = 0
) -> Annotated[str, "The path to the captured screenshot."] :
"""This tool enables taking screenshots of a webpage through a headless browser"""
Note: The
browser_screenshotfunction description on the last line is a critical component. Without a description, Q CLI will not be able to discover your tool.
Notice that we only require one parameter url in our browser_screenshot function — all other parameters are optional. This gives us flexibility to leverage defaults for generic screenshots or a full suite of parameters for specific requests.
Verbosity in annotations empowers the consuming model with context when destructuring natural language into tool usage.
Since our tool will be used asynchronously, we’ll use an async instance of playwright:
async with async_playwright() as playwright:
Next, we’ll set the browser we’re going to use as chromium and get a reference to browser and page to perform our operations:
browser_type = playwright.chromium
browser = await browser_type.launch()
page = await browser.new_page()
To create a dynamic filename, we’ll leverage time and os to get the current working directory to save our screenshots. This implementation can be extended to leverage a temp directory if needed, but that’s outside the scope of this example.
# Construct output file path
time_str = time.strftime("%Y%m%d-%H%M%S")
filename = f'screenshot-{browser_type.name}-{time_str}.png'
screenshot_path = os.path.join(os.getcwd(), filename)
We’ll need to set up our defaults for screen size in the event the prompt isn’t asking for a specific dimension set and also set the page viewport ahead of navigation:
# Default to 1920x1080 if not provided
default_width = 1920
default_height = 1080
viewport_width = viewport_width or default_width
viewport_height = viewport_height or default_height
# Set viewport size for screenshot
await page.set_viewport_size({ "width": viewport_width, "height": viewport_height })
# Navigate to URL
await page.goto(url)
Similarly, we’ll take the same approach for scroll offset:
# Scroll to if provided
if scroll_offset != 0:
await page.evaluate(f'window.scrollTo(0, {scroll_offset})')
Finally, we’ll capture the screenshot and return the path so our model can reference the screenshot in a prompt:
# Capture screenshot
await page.screenshot(path=screenshot_path, full_page=full_page)
# Clean up
await browser.close()
return screenshot_path
Putting it all together, you should now have a file that looks like this:
# ./src/vision_mcp_server/vision.py
import os
import time
from playwright.async_api import async_playwright
from typing import Annotated
class Vision:
async def browser_screenshot(
self,
url: Annotated[str, "The URL of the webpage to screenshot."],
viewport_width: Annotated[int, "The width of the browser window when taking a screenshot."] = None,
viewport_height: Annotated[int, "The height of the browser window when taking a screenshot."] = None,
full_page: Annotated[bool, "A flag to determine whether or not to capture the full height of the webpage."] = False,
scroll_offset: Annotated[int, "The number of pixels to scroll down, from the top of the page, before taking a screenshot."] = 0
) -> Annotated[str, "The path to the captured screenshot."] :
"""This tool enables taking screenshots of a webpage through a headless browser"""
async with async_playwright() as playwright:
browser_type = playwright.chromium
browser = await browser_type.launch()
page = await browser.new_page()
# Construct output file path
time_str = time.strftime("%Y%m%d-%H%M%S")
filename = f'screenshot-{browser_type.name}-{time_str}.png'
screenshot_path = os.path.join(os.getcwd(), filename)
# Default to 1920x1080 if not provided
default_width = 1920
default_height = 1080
viewport_width = viewport_width or default_width
viewport_height = viewport_height or default_height
# Set viewport size for screenshot
await page.set_viewport_size({ "width": viewport_width, "height": viewport_height })
# Navigate to URL
await page.goto(url)
# Scroll to if provided
if scroll_offset != 0:
await page.evaluate(f'window.scrollTo(0, {scroll_offset})')
# Capture screenshot
await page.screenshot(path=screenshot_path, full_page=full_page)
# Clean up
await browser.close()
return screenshot_path
With our Vision class complete, let’s hop back over to our server.py to import the class, instantiate it, and register the tool to our MCP server:
# Import Vision class
from .vision import Vision
# Instantiate
vision = Vision()
# Register the tool
mcp.tool(vision.browser_screenshot)
The complete server.py will now look like this:
# ./src/vision_mcp_server/server.py
from fastmcp import FastMCP
from .vision import Vision
mcp = FastMCP(
"vision-mcp-server",
instructions="The Vision MCP server provides specialized tools for analyzing webpages through a headless browser. Use these tools when you need to take screenshots, analyze live webpages for debugging, or view different breakpoints."
)
vision = Vision()
mcp.tool(vision.browser_screenshot)
def main():
mcp.run()
if __name__ == "__main__":
main()
You should now have a directory tree like this:
.
├── pyproject.toml
├── README.md
├── src
│ └── vision_mcp_server
│ ├── server.py
│ └── vision.py
└── uv.lock

Testing It Out
Let’s create a mock client to test out our MCP server to make sure it’s functioning properly:
Note: replace
https://www.example.comwith a valid URL for testing
# ./client-test.py
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
client = Client(StdioTransport("vision-mcp-server", []))
async def main():
async with client:
result = await client.call_tool("browser_screenshot", {"url": "https://www.example.com"})
print(result)
asyncio.run(main())
Run the test with:
uv run client-test.py
You should now have a screenshot saved to your working directory!
Test it out with additional arguments and different domains to explore all of the tool’s capabilities and extend it to do more.
Installing the Package
Now that our MCP server is complete and tested, let’s install it globally so we can reach it outside of the virtual environment:
pipx install -e .
Note: when you need to hack on it and make updates, run the following command to refresh the package:
pipx install -e . --force
Conclusion & Next Steps
You now have a functional MCP server with browser screenshot capabilities.
Your MCP server handles webpage capture, implements proper MCP protocol communication, and is packaged for local testing.
Note: This is not production code, to get it production ready it should include error handling for network issues, invalid URLs, etc.
Part 2 covers the integration process with Q CLI. Once connected, you’ll have direct visual context in your development workflow — eliminating the need to describe UI issues through text and enabling more efficient debugging sessions.
The technical groundwork is done. Now it’s time to make it operational in Part 2.
메타데이터
- post_id
- ae87cedbb90e
- slug
- the-mcp-hack-that-gave-q-cli-vision-part-1-ae87cedbb90e
- url
- https://medium.com/@thisiskeith/the-mcp-hack-that-gave-q-cli-vision-part-1-ae87cedbb90e
- canonical_url
- https://medium.com/@thisiskeith/the-mcp-hack-that-gave-q-cli-vision-part-1-ae87cedbb90e
- author_url
- https://medium.com/@thisiskeith
- status
- ok
- fetched_at
- 2026-07-13 14:44:14