diff --git a/docs/docs.json b/docs/docs.json index a213165045..70b6d2173a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -254,6 +254,7 @@ "integrations/claude-desktop", "integrations/cursor", "integrations/gemini-cli", + "integrations/goose", "integrations/mcp-json-configuration" ] }, @@ -307,6 +308,7 @@ "python-sdk/fastmcp-cli-install-claude_desktop", "python-sdk/fastmcp-cli-install-cursor", "python-sdk/fastmcp-cli-install-gemini_cli", + "python-sdk/fastmcp-cli-install-goose", "python-sdk/fastmcp-cli-install-mcp_json", "python-sdk/fastmcp-cli-install-shared" ] diff --git a/docs/integrations/chatgpt.mdx b/docs/integrations/chatgpt.mdx index d674a21e21..92ddb74041 100644 --- a/docs/integrations/chatgpt.mdx +++ b/docs/integrations/chatgpt.mdx @@ -5,7 +5,7 @@ description: Connect FastMCP servers to ChatGPT in Chat and Deep Research modes icon: message-smile --- -ChatGPT supports MCP servers through remote HTTP connections in two modes: **Chat mode** for interactive conversations and **Deep Research mode** for comprehensive information retrieval. +[ChatGPT](https://chatgpt.com/) supports MCP servers through remote HTTP connections in two modes: **Chat mode** for interactive conversations and **Deep Research mode** for comprehensive information retrieval. **Developer Mode Required for Chat Mode**: To use MCP servers in regular ChatGPT conversations, you must first enable Developer Mode in your ChatGPT settings. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users. diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 2b04c10a8d..8098ff51e7 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -10,7 +10,7 @@ import { LocalFocusTip } from "/snippets/local-focus.mdx" -Claude Code supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. +[Claude Code](https://docs.anthropic.com/en/docs/claude-code) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. ## Requirements diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx index 598e4be5f5..4478bcc37a 100644 --- a/docs/integrations/claude-desktop.mdx +++ b/docs/integrations/claude-desktop.mdx @@ -10,7 +10,7 @@ import { LocalFocusTip } from "/snippets/local-focus.mdx" -Claude Desktop supports MCP servers through local STDIO connections and remote servers (beta), allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. +[Claude Desktop](https://www.claude.com/download) supports MCP servers through local STDIO connections and remote servers (beta), allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. Remote MCP server support is currently in beta and available for users on Claude Pro, Max, Team, and Enterprise plans (as of June 2025). Most users will still need to use local STDIO connections. diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index 5b98cbd1af..da0744ee02 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -10,7 +10,7 @@ import { LocalFocusTip } from "/snippets/local-focus.mdx" -Cursor supports MCP servers through multiple transport methods including STDIO, SSE, and Streamable HTTP, allowing you to extend Cursor's AI assistant with custom tools, resources, and prompts from your FastMCP servers. +[Cursor](https://www.cursor.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and Streamable HTTP, allowing you to extend Cursor's AI assistant with custom tools, resources, and prompts from your FastMCP servers. ## Requirements diff --git a/docs/integrations/gemini-cli.mdx b/docs/integrations/gemini-cli.mdx index 591ab4d5d9..10613fb1bc 100644 --- a/docs/integrations/gemini-cli.mdx +++ b/docs/integrations/gemini-cli.mdx @@ -10,7 +10,7 @@ import { LocalFocusTip } from "/snippets/local-focus.mdx" -Gemini CLI supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Gemini's capabilities with custom tools, resources, and prompts from your FastMCP servers. +[Gemini CLI](https://geminicli.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Gemini's capabilities with custom tools, resources, and prompts from your FastMCP servers. ## Requirements diff --git a/docs/integrations/goose.mdx b/docs/integrations/goose.mdx new file mode 100644 index 0000000000..fc2ff8e390 --- /dev/null +++ b/docs/integrations/goose.mdx @@ -0,0 +1,178 @@ +--- +title: Goose 🤝 FastMCP +sidebarTitle: Goose +description: Install and use FastMCP servers in Goose +icon: message-smile +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" +import { LocalFocusTip } from "/snippets/local-focus.mdx" + + + +[Goose](https://block.github.io/goose/) is an open-source AI agent from Block that supports MCP servers as extensions. FastMCP can install your server directly into Goose using its deeplink protocol — one command opens Goose with an install dialog ready to go. + +## Requirements + +This integration uses Goose's deeplink protocol to register your server as a STDIO extension running via `uvx`. You must have Goose installed on your system for the deeplink to open automatically. + +For remote deployments, configure your FastMCP server with HTTP transport and add it to Goose directly using `goose configure` or the config file. + +## Create a Server + +The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. + +```python server.py +import random +from fastmcp import FastMCP + +mcp = FastMCP(name="Dice Roller") + +@mcp.tool +def roll_dice(n_dice: int) -> list[int]: + """Roll `n_dice` 6-sided dice and return the results.""" + return [random.randint(1, 6) for _ in range(n_dice)] + +if __name__ == "__main__": + mcp.run() +``` + +## Install the Server + +### FastMCP CLI + + +The easiest way to install a FastMCP server in Goose is using the `fastmcp install goose` command. This generates a `goose://` deeplink and opens it, prompting Goose to install the server. + +```bash +fastmcp install goose server.py +``` + +The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: + +```bash +# These are equivalent if your server object is named 'mcp' +fastmcp install goose server.py +fastmcp install goose server.py:mcp + +# Use explicit object name if your server has a different name +fastmcp install goose server.py:my_custom_server +``` + +Under the hood, the generated command uses `uvx` to run your server in an isolated environment. Goose requires `uvx` rather than `uv run`, so the install produces a command like: + +```bash +uvx --with pandas fastmcp run /path/to/server.py +``` + +#### Dependencies + +Use the `--with` flag to specify additional packages your server needs: + +```bash +fastmcp install goose server.py --with pandas --with requests +``` + +Alternatively, you can use a `fastmcp.json` configuration file (recommended): + +```json fastmcp.json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + }, + "environment": { + "dependencies": ["pandas", "requests"] + } +} +``` + +#### Python Version + +Use `--python` to specify which Python version your server should use: + +```bash +fastmcp install goose server.py --python 3.11 +``` + + +The Goose install uses `uvx`, which does not support `--project`, `--with-requirements`, or `--with-editable`. If you need these options, use `fastmcp install mcp-json` to generate a full configuration and add it to Goose manually. + + +#### Environment Variables + +Goose's deeplink protocol does not support environment variables. If your server needs them (like API keys), you have two options: + +1. **Configure after install**: Run `goose configure` and add environment variables to the extension. +2. **Manual config**: Use `fastmcp install mcp-json` to generate the full configuration, then add it to `~/.config/goose/config.yaml` with the `envs` field. + +### Manual Configuration + +For more control, you can manually edit Goose's configuration file at `~/.config/goose/config.yaml`: + +```yaml +extensions: + dice-roller: + name: Dice Roller + cmd: uvx + args: [fastmcp, run, /path/to/server.py] + enabled: true + type: stdio + timeout: 300 +``` + +#### Dependencies + +When manually configuring, add packages using `--with` flags in the args: + +```yaml +extensions: + dice-roller: + name: Dice Roller + cmd: uvx + args: [--with, pandas, --with, requests, fastmcp, run, /path/to/server.py] + enabled: true + type: stdio + timeout: 300 +``` + +#### Environment Variables + +Environment variables can be specified in the `envs` field: + +```yaml +extensions: + weather-server: + name: Weather Server + cmd: uvx + args: [fastmcp, run, /path/to/weather_server.py] + enabled: true + envs: + API_KEY: your-api-key + DEBUG: "true" + type: stdio + timeout: 300 +``` + +You can also use `goose configure` to add extensions interactively, which prompts for environment variables. + + +**`uvx` (from `uv`) must be installed and available in your system PATH**. Goose uses `uvx` to run Python-based extensions in isolated environments. + + +## Using the Server + +Once your server is installed, you can start using your FastMCP server with Goose. + +Try asking Goose something like: + +> "Roll some dice for me" + +Goose will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: + +> 🎲 Here are your dice rolls: 4, 6, 4 +> +> You rolled 3 dice with a total of 14! + +Goose can now access all the tools, resources, and prompts you've defined in your FastMCP server. diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 078aa37cd5..8662e26e58 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -301,6 +301,7 @@ Install a MCP server in MCP client applications. FastMCP currently supports the - **Claude Desktop** - Installs via direct configuration file modification - **Cursor** - Installs via deeplink that opens Cursor for user confirmation - **Gemini CLI** - Installs via Gemini CLI's built-in MCP management system +- **Goose** - Installs via deeplink that opens Goose for user confirmation (uses `uvx`) - **MCP JSON** - Generates standard MCP JSON configuration for manual use - **Stdio** - Outputs the shell command to run a server over stdio transport @@ -309,6 +310,7 @@ fastmcp install claude-code server.py fastmcp install claude-desktop server.py fastmcp install cursor server.py fastmcp install gemini-cli server.py +fastmcp install goose server.py fastmcp install mcp-json server.py fastmcp install stdio server.py ``` @@ -384,6 +386,9 @@ fastmcp install cursor server.py --env API_KEY=secret --env DEBUG=true # Install with environment file fastmcp install cursor server.py --env-file .env +# Install in Goose (uses uvx deeplink) +fastmcp install goose server.py --with pandas + # Install with specific Python version fastmcp install claude-desktop server.py --python 3.11 diff --git a/src/fastmcp/cli/install/__init__.py b/src/fastmcp/cli/install/__init__.py index 5b8e30f151..35b8d832d0 100644 --- a/src/fastmcp/cli/install/__init__.py +++ b/src/fastmcp/cli/install/__init__.py @@ -6,6 +6,7 @@ from .claude_desktop import claude_desktop_command from .cursor import cursor_command from .gemini_cli import gemini_cli_command +from .goose import goose_command from .mcp_json import mcp_json_command from .stdio import stdio_command @@ -20,5 +21,6 @@ install_app.command(claude_desktop_command, name="claude-desktop") install_app.command(cursor_command, name="cursor") install_app.command(gemini_cli_command, name="gemini-cli") +install_app.command(goose_command, name="goose") install_app.command(mcp_json_command, name="mcp-json") install_app.command(stdio_command, name="stdio") diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 88693ec386..0358c3261a 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -1,12 +1,10 @@ """Cursor integration for FastMCP install using Cyclopts.""" import base64 -import os -import subprocess import sys from pathlib import Path from typing import Annotated -from urllib.parse import quote, urlparse +from urllib.parse import quote import cyclopts from rich import print @@ -15,6 +13,7 @@ from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment +from .shared import open_deeplink as _shared_open_deeplink from .shared import process_common_args logger = get_logger(__name__) @@ -46,7 +45,7 @@ def generate_cursor_deeplink( def open_deeplink(deeplink: str) -> bool: - """Attempt to open a deeplink URL using the system's default handler. + """Attempt to open a Cursor deeplink URL using the system's default handler. Args: deeplink: The deeplink URL to open @@ -54,21 +53,7 @@ def open_deeplink(deeplink: str) -> bool: Returns: True if the command succeeded, False otherwise """ - parsed = urlparse(deeplink) - if parsed.scheme != "cursor": - logger.warning(f"Invalid deeplink scheme: {parsed.scheme}") - return False - - try: - if sys.platform == "darwin": # macOS - subprocess.run(["open", deeplink], check=True, capture_output=True) - elif sys.platform == "win32": # Windows - os.startfile(deeplink) - else: # Linux and others - subprocess.run(["xdg-open", deeplink], check=True, capture_output=True) - return True - except (subprocess.CalledProcessError, FileNotFoundError, OSError): - return False + return _shared_open_deeplink(deeplink, expected_scheme="cursor") def install_cursor_workspace( diff --git a/src/fastmcp/cli/install/goose.py b/src/fastmcp/cli/install/goose.py new file mode 100644 index 0000000000..16161dcf1d --- /dev/null +++ b/src/fastmcp/cli/install/goose.py @@ -0,0 +1,209 @@ +"""Goose integration for FastMCP install using Cyclopts.""" + +import re +import sys +from pathlib import Path +from typing import Annotated +from urllib.parse import quote + +import cyclopts +from rich import print + +from fastmcp.utilities.logging import get_logger + +from .shared import open_deeplink, process_common_args + +logger = get_logger(__name__) + + +def _slugify(name: str) -> str: + """Convert a display name to a URL-safe identifier. + + Lowercases, replaces non-alphanumeric runs with hyphens, + and strips leading/trailing hyphens. + """ + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "fastmcp-server" + + +def generate_goose_deeplink( + name: str, + command: str, + args: list[str], + *, + description: str = "MCP server installed via FastMCP", +) -> str: + """Generate a Goose deeplink for installing an MCP extension. + + Args: + name: Human-readable display name for the extension. + command: The executable command (e.g. "uv"). + args: Arguments to the command. + description: Short description shown in Goose. + + Returns: + A goose://extension?... deeplink URL. + """ + extension_id = _slugify(name) + + params: list[str] = [f"cmd={quote(command, safe='')}"] + for arg in args: + params.append(f"arg={quote(arg, safe='')}") + params.append(f"id={quote(extension_id, safe='')}") + params.append(f"name={quote(name, safe='')}") + params.append(f"description={quote(description, safe='')}") + + return f"goose://extension?{'&'.join(params)}" + + +def _build_uvx_command( + server_spec: str, + *, + python_version: str | None = None, + with_packages: list[str] | None = None, +) -> list[str]: + """Build a uvx command for running a FastMCP server. + + Goose requires uvx (not uv run) as the command. The uvx format is: + uvx [--with pkg] [--python X] fastmcp run + + uvx automatically infers that the `fastmcp` command comes from the + `fastmcp` package, so --from is not needed. + """ + args: list[str] = ["uvx"] + + if python_version: + args.extend(["--python", python_version]) + + for pkg in sorted(set(with_packages or [])): + if pkg != "fastmcp": + args.extend(["--with", pkg]) + + args.extend(["fastmcp", "run", server_spec]) + return args + + +def install_goose( + file: Path, + server_object: str | None, + name: str, + *, + with_packages: list[str] | None = None, + python_version: str | None = None, +) -> bool: + """Install FastMCP server in Goose via deeplink. + + Args: + file: Path to the server file. + server_object: Optional server object name (for :object suffix). + name: Name for the extension in Goose. + with_packages: Optional list of additional packages to install. + python_version: Optional Python version to use. + + Returns: + True if installation was successful, False otherwise. + """ + if server_object: + server_spec = f"{file.resolve()}:{server_object}" + else: + server_spec = str(file.resolve()) + + full_command = _build_uvx_command( + server_spec, + python_version=python_version, + with_packages=with_packages, + ) + + deeplink = generate_goose_deeplink( + name=name, + command=full_command[0], + args=full_command[1:], + ) + + print(f"[blue]Opening Goose to install '{name}'[/blue]") + + if open_deeplink(deeplink, expected_scheme="goose"): + print("[green]Goose should now open with the installation dialog[/green]") + return True + else: + print( + "[red]Could not open Goose automatically.[/red]\n" + f"[blue]Please copy this link and open it in Goose: {deeplink}[/blue]" + ) + return False + + +async def goose_command( + server_spec: str, + *, + server_name: Annotated[ + str | None, + cyclopts.Parameter( + name=["--name", "-n"], + help="Custom name for the extension in Goose", + ), + ] = None, + with_packages: Annotated[ + list[str] | None, + cyclopts.Parameter( + "--with", + help="Additional packages to install (can be used multiple times)", + ), + ] = None, + env_vars: Annotated[ + list[str] | None, + cyclopts.Parameter( + "--env", + help="Environment variables in KEY=VALUE format (can be used multiple times)", + ), + ] = None, + env_file: Annotated[ + Path | None, + cyclopts.Parameter( + "--env-file", + help="Load environment variables from .env file", + ), + ] = None, + python: Annotated[ + str | None, + cyclopts.Parameter( + "--python", + help="Python version to use (e.g., 3.10, 3.11)", + ), + ] = None, +) -> None: + """Install an MCP server in Goose. + + Uses uvx to run the server. Environment variables are not included + in the deeplink; use `fastmcp install mcp-json` to generate a full + config for manual installation. + + Args: + server_spec: Python file to install, optionally with :object suffix + """ + with_packages = with_packages or [] + env_vars = env_vars or [] + + if env_vars or env_file: + print( + "[red]Goose deeplinks cannot include environment variables.[/red]\n" + "[yellow]Use `fastmcp install mcp-json` to generate a config, then add it " + "to your Goose config file with env vars: " + "https://block.github.io/goose/docs/getting-started/using-extensions/#config-entry[/yellow]" + ) + sys.exit(1) + + file, server_object, name, with_packages, _env_dict = await process_common_args( + server_spec, server_name, with_packages, env_vars, env_file + ) + + success = install_goose( + file=file, + server_object=server_object, + name=name, + with_packages=with_packages, + python_version=python, + ) + + if not success: + sys.exit(1) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index f88febfd7d..fe980dce64 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -1,8 +1,11 @@ """Shared utilities for install commands.""" import json +import os +import subprocess import sys from pathlib import Path +from urllib.parse import urlparse from dotenv import dotenv_values from pydantic import ValidationError @@ -140,3 +143,32 @@ async def process_common_args( env_dict[key] = value return file, server_object, name, with_packages, env_dict + + +def open_deeplink(url: str, *, expected_scheme: str) -> bool: + """Attempt to open a deeplink URL using the system's default handler. + + Args: + url: The deeplink URL to open. + expected_scheme: The URL scheme to validate (e.g. "cursor", "goose"). + + Returns: + True if the command succeeded, False otherwise. + """ + parsed = urlparse(url) + if parsed.scheme != expected_scheme: + logger.warning( + f"Invalid deeplink scheme: {parsed.scheme}, expected {expected_scheme}" + ) + return False + + try: + if sys.platform == "darwin": + subprocess.run(["open", url], check=True, capture_output=True) + elif sys.platform == "win32": + os.startfile(url) + else: + subprocess.run(["xdg-open", url], check=True, capture_output=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return False diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 7e63e7b9fc..476b678ef7 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -181,7 +181,7 @@ def test_open_deeplink_windows(self): """Test opening deeplink on Windows.""" with patch("sys.platform", "win32"): with patch( - "fastmcp.cli.install.cursor.os.startfile", create=True + "fastmcp.cli.install.shared.os.startfile", create=True ) as mock_startfile: result = open_deeplink("cursor://test") @@ -251,7 +251,7 @@ def test_open_deeplink_windows_oserror(self): """Test handling of OSError on Windows.""" with patch("sys.platform", "win32"): with patch( - "fastmcp.cli.install.cursor.os.startfile", create=True + "fastmcp.cli.install.shared.os.startfile", create=True ) as mock_startfile: mock_startfile.side_effect = OSError("File not found") result = open_deeplink("cursor://test") diff --git a/tests/cli/test_goose.py b/tests/cli/test_goose.py new file mode 100644 index 0000000000..6108eeff66 --- /dev/null +++ b/tests/cli/test_goose.py @@ -0,0 +1,298 @@ +from pathlib import Path +from unittest.mock import patch +from urllib.parse import parse_qs, unquote, urlparse + +import pytest + +from fastmcp.cli.install.goose import ( + _build_uvx_command, + _slugify, + generate_goose_deeplink, + goose_command, + install_goose, +) + + +class TestSlugify: + def test_simple_name(self): + assert _slugify("My Server") == "my-server" + + def test_special_characters(self): + assert _slugify("my_server (v2.0)") == "my-server-v2-0" + + def test_already_slugified(self): + assert _slugify("my-server") == "my-server" + + def test_empty_string(self): + assert _slugify("") == "fastmcp-server" + + def test_only_special_chars(self): + assert _slugify("!!!") == "fastmcp-server" + + def test_consecutive_hyphens_collapsed(self): + assert _slugify("a---b") == "a-b" + + def test_leading_trailing_stripped(self): + assert _slugify("--hello--") == "hello" + + +class TestBuildUvxCommand: + def test_basic(self): + cmd = _build_uvx_command("server.py") + assert cmd == ["uvx", "fastmcp", "run", "server.py"] + + def test_with_python_version(self): + cmd = _build_uvx_command("server.py", python_version="3.11") + assert cmd == [ + "uvx", + "--python", + "3.11", + "fastmcp", + "run", + "server.py", + ] + + def test_with_packages(self): + cmd = _build_uvx_command("server.py", with_packages=["numpy", "pandas"]) + assert "--with" in cmd + assert "numpy" in cmd + assert "pandas" in cmd + + def test_fastmcp_not_in_with(self): + cmd = _build_uvx_command("server.py", with_packages=["fastmcp", "numpy"]) + # fastmcp is the command itself, so it shouldn't appear in --with + with_indices = [i for i, v in enumerate(cmd) if v == "--with"] + with_values = [cmd[i + 1] for i in with_indices] + assert "fastmcp" not in with_values + + def test_packages_sorted_and_deduplicated(self): + cmd = _build_uvx_command( + "server.py", with_packages=["pandas", "numpy", "pandas"] + ) + with_indices = [i for i, v in enumerate(cmd) if v == "--with"] + with_values = [cmd[i + 1] for i in with_indices] + assert with_values == ["numpy", "pandas"] + + def test_server_spec_with_object(self): + cmd = _build_uvx_command("server.py:app") + assert cmd[-1] == "server.py:app" + + +class TestGooseDeeplinkGeneration: + def test_basic_deeplink(self): + deeplink = generate_goose_deeplink( + name="test-server", + command="uvx", + args=["fastmcp", "run", "server.py"], + ) + assert deeplink.startswith("goose://extension?") + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert params["cmd"] == ["uvx"] + assert params["name"] == ["test-server"] + assert params["id"] == ["test-server"] + + def test_special_characters_in_name(self): + deeplink = generate_goose_deeplink( + name="my server (test)", + command="uvx", + args=["fastmcp", "run", "server.py"], + ) + assert "name=my%20server%20%28test%29" in deeplink + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert params["id"] == ["my-server-test"] + + def test_url_injection_protection(self): + deeplink = generate_goose_deeplink( + name="test&evil=true", + command="uvx", + args=["fastmcp", "run", "server.py"], + ) + assert "name=test%26evil%3Dtrue" in deeplink + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert params["name"] == ["test&evil=true"] + + def test_dangerous_characters_encoded(self): + dangerous_names = [ + ("test|calc", "test%7Ccalc"), + ("test;calc", "test%3Bcalc"), + ("testcalc", "test%3Ecalc"), + ("test`calc", "test%60calc"), + ("test$calc", "test%24calc"), + ("test'calc", "test%27calc"), + ('test"calc', "test%22calc"), + ("test calc", "test%20calc"), + ("test#anchor", "test%23anchor"), + ("test?query=val", "test%3Fquery%3Dval"), + ] + for dangerous_name, expected_encoded in dangerous_names: + deeplink = generate_goose_deeplink( + name=dangerous_name, command="uvx", args=["fastmcp", "run", "server.py"] + ) + assert f"name={expected_encoded}" in deeplink, ( + f"Failed to encode {dangerous_name}" + ) + + def test_custom_description(self): + deeplink = generate_goose_deeplink( + name="my-server", + command="uvx", + args=["fastmcp", "run", "server.py"], + description="My custom MCP server", + ) + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert params["description"] == ["My custom MCP server"] + + def test_args_with_special_characters(self): + deeplink = generate_goose_deeplink( + name="test", + command="uvx", + args=[ + "--with", + "numpy>=1.20", + "fastmcp", + "run", + "server.py:MyApp", + ], + ) + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert "numpy>=1.20" in params["arg"] + assert "server.py:MyApp" in params["arg"] + + def test_empty_args(self): + deeplink = generate_goose_deeplink(name="simple", command="python", args=[]) + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert "arg" not in params + assert params["cmd"] == ["python"] + + def test_command_with_path(self): + deeplink = generate_goose_deeplink( + name="test", + command="/usr/local/bin/uvx", + args=["fastmcp", "run", "server.py"], + ) + parsed = urlparse(deeplink) + params = parse_qs(parsed.query) + assert params["cmd"] == ["/usr/local/bin/uvx"] + + +class TestInstallGoose: + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_success(self, mock_print, mock_open): + mock_open.return_value = True + result = install_goose( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + assert result is True + mock_open.assert_called_once() + call_url = mock_open.call_args[0][0] + assert call_url.startswith("goose://extension?") + assert mock_open.call_args[1] == {"expected_scheme": "goose"} + + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_success_uses_uvx(self, mock_print, mock_open): + mock_open.return_value = True + install_goose( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + call_url = mock_open.call_args[0][0] + parsed = urlparse(call_url) + params = parse_qs(parsed.query) + assert params["cmd"] == ["uvx"] + assert "fastmcp" in params["arg"] + + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_failure(self, mock_print, mock_open): + mock_open.return_value = False + result = install_goose( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + assert result is False + + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_with_server_object(self, mock_print, mock_open): + mock_open.return_value = True + install_goose( + file=Path("/path/to/server.py"), + server_object="app", + name="test-server", + ) + call_url = mock_open.call_args[0][0] + parsed = urlparse(call_url) + params = parse_qs(parsed.query) + args = params["arg"] + assert any("server.py:app" in unquote(a) for a in args) + + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_with_packages(self, mock_print, mock_open): + mock_open.return_value = True + install_goose( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + with_packages=["numpy", "pandas"], + ) + call_url = mock_open.call_args[0][0] + parsed = urlparse(call_url) + params = parse_qs(parsed.query) + args = params["arg"] + assert "numpy" in args + assert "pandas" in args + + @patch("fastmcp.cli.install.goose.open_deeplink") + @patch("fastmcp.cli.install.goose.print") + def test_fallback_message_on_failure(self, mock_print, mock_open): + mock_open.return_value = False + install_goose( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + fallback_calls = [ + call + for call in mock_print.call_args_list + if "copy this link" in str(call).lower() or "goose://" in str(call) + ] + assert len(fallback_calls) > 0 + + +class TestGooseCommand: + @patch("fastmcp.cli.install.goose.install_goose") + @patch("fastmcp.cli.install.goose.process_common_args") + async def test_basic(self, mock_process, mock_install): + mock_process.return_value = (Path("server.py"), None, "test-server", [], {}) + mock_install.return_value = True + await goose_command("server.py") + mock_install.assert_called_once_with( + file=Path("server.py"), + server_object=None, + name="test-server", + with_packages=[], + python_version=None, + ) + + @patch("fastmcp.cli.install.goose.install_goose") + @patch("fastmcp.cli.install.goose.process_common_args") + async def test_failure_exits(self, mock_process, mock_install): + mock_process.return_value = (Path("server.py"), None, "test-server", [], {}) + mock_install.return_value = False + with pytest.raises(SystemExit) as exc_info: + await goose_command("server.py") + assert exc_info.value.code == 1 diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 5a883665cd..81fde3c1c9 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -26,6 +26,7 @@ def test_install_commands_registered(self): install_app.parse_args(["claude-desktop", "--help"]) install_app.parse_args(["cursor", "--help"]) install_app.parse_args(["gemini-cli", "--help"]) + install_app.parse_args(["goose", "--help"]) install_app.parse_args(["mcp-json", "--help"]) install_app.parse_args(["stdio", "--help"]) except SystemExit: @@ -165,6 +166,53 @@ def test_cursor_with_options(self): assert bound.arguments["server_name"] == "test-server" +class TestGooseInstall: + """Test goose install command.""" + + def test_goose_basic(self): + """Test basic goose install command parsing.""" + command, bound, _ = install_app.parse_args( + ["goose", "server.py", "--name", "test-server"] + ) + + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["server_name"] == "test-server" + + def test_goose_with_options(self): + """Test goose install with various options.""" + command, bound, _ = install_app.parse_args( + [ + "goose", + "server.py", + "--name", + "test-server", + "--with", + "package1", + "--with", + "package2", + "--env", + "VAR1=value1", + ] + ) + + assert bound.arguments["with_packages"] == ["package1", "package2"] + assert bound.arguments["env_vars"] == ["VAR1=value1"] + + def test_goose_with_python(self): + """Test goose install with --python option.""" + command, bound, _ = install_app.parse_args( + [ + "goose", + "server.py", + "--python", + "3.11", + ] + ) + + assert bound.arguments["python"] == "3.11" + + class TestMcpJsonInstall: """Test mcp-json install command.""" @@ -323,6 +371,7 @@ def test_install_minimal_args(self): ["claude-desktop", "server.py"], ["cursor", "server.py"], ["gemini-cli", "server.py"], + ["goose", "server.py"], ["stdio", "server.py"], ] @@ -351,6 +400,7 @@ def test_python_option(self): ["claude-desktop", "server.py", "--python", "3.11"], ["cursor", "server.py", "--python", "3.11"], ["gemini-cli", "server.py", "--python", "3.11"], + ["goose", "server.py", "--python", "3.11"], ["mcp-json", "server.py", "--python", "3.11"], ["stdio", "server.py", "--python", "3.11"], ]