Skip to main content
Glama

Goose App Maker MCP

by michaelneale
main.py29.8 kB
from mcp.server.fastmcp import FastMCP import os import subprocess import logging import time import json import shutil import http.server import socketserver import threading from typing import Dict, Any, List from pathlib import Path # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) logger.info("Starting Goose App Maker MCP server...") # Define the app storage directory APP_DIR = os.path.expanduser("~/.config/goose/app-maker-apps") os.makedirs(APP_DIR, exist_ok=True) logger.info(f"Using app directory: {APP_DIR}") # Define paths for resources SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) RESOURCES_DIR = os.path.join(SCRIPT_DIR, "resources") README_PATH = os.path.join(SCRIPT_DIR, "README.md") GOOSE_API_PATH = os.path.join(RESOURCES_DIR, "kitchen-sink/goose_api.js") # Global variable to store the HTTP server instance http_server = None server_port = 8000 # Default port # Global variable to store app response app_response = None response_lock = threading.Condition() response_ready = False # Global variable to store app errors app_errors = [] instructions = """ This extension allows creation and running of casual web apps for Goose. You are an expert html5/CSS/js web app author for casual "apps" for goose. Use this toolkit when running/making apps for goose. Your job is to help users create and manage web applications that are stored in the ~/.config/goose/app-maker-apps directory. In some cases you will be creating from scratch (or building from other example app), or modifying an existing app In other cases you will just be serving up an existing app that has been downloaded and is available (will not be need to be modified unless the user explicitaly asks to) In some other cases you may just be answering a query/taking an action that an app requires and you may not need to edit the app. The user may not be an professional developer (however they may indicate they are, based on how they interact with you, in which case, you may be working on apps using the devleoper extension). This extension is to supplement goose for casual sharable goose apps. You can also serve up apps via a built in server. You can: 1. Generate new web applications based on user requirements 2. Serve existing web applications locally 3. Modify existing web applications 4. List available web applications 5. Open web applications in the default browser 6. Take some existing web app/html and bring it into the app-maker-apps directory for serving. When generating web apps: - Create clean, modern, and responsive designs - They should be beautiful and user-friendly - Ensure proper HTML5, CSS, and JavaScript structure - You can embed data in the app if it is static and non PII, and safe to do so - Of course the usual html5 browser apis are available to you (such as fetch, local storage, etc) - Use the goose_api.js when data needs to be dynamic (see below) for powerful data and api and agentic functionality - Open the app as it is built with the default browser to show the user, and invite feedback - Use other tools as available to assist in building the app (such as screenshot for visual review) Each app is stored in its own directory within ~/.config/goose/app-maker-apps. Once an app is created, you can modify or replace contents of its files using tools available. Typically there is an index.html, style.css, and script.js file (and the goose_api.js helper) - but you don't have to stick to this structure if you know better. The directory ~/.config/goose/app-maker-apps/[app-name]/ is where the app is stored. Resources: - The resources directory is located at: {resources_dir} which has utilities and examples you can refer to. - For example apps, refer to the examples in the [README.md]({readme_path}) - For apps requiring dynamic functionality or access to data sources/services, include [goose_api.js]({goose_api_path}) in your app Using goose_api.js for dynamic content: - Include it in your HTML: <script src="goose_api.js"></script> - Use these functions to get responses from Goose: - gooseRequestText(query) - Returns a text/paragraph response - gooseRequestList(query) - Returns a list of items - gooseRequestTable(query, columns) - Returns tabular data (columns required) - For error reporting: - reportError(errorMessage) - Reports errors back to Goose - Example: const response = await gooseRequestList("List 5 best movies"); - See {readme_path} for more detailed examples Some of the tools available: app_create - use this when starting new app_list - find existing apps app_serve - serve an app locally app_open - open an app in a browser (macos) app_response - for sending data back to the app front end app_error - use this to see if there are error from the app, useful when modifying an app """ # Format the instructions with dynamic paths instructions = instructions.format( resources_dir=RESOURCES_DIR, readme_path=README_PATH, goose_api_path=GOOSE_API_PATH ) mcp = FastMCP("Goose App Maker", instructions=instructions) @mcp.tool() def app_list() -> Dict[str, Any]: """ List all available web applications. Returns: A dictionary containing the list of available apps and their details """ try: apps = [] for app_dir in Path(APP_DIR).iterdir(): if app_dir.is_dir(): app_info = { "name": app_dir.name, "path": str(app_dir), "files": [] } # Get the list of files for file_path in app_dir.glob("**/*"): if file_path.is_file(): rel_path = str(file_path.relative_to(app_dir)) app_info["files"].append(rel_path) # Check if there's a goose-app-manifest.json file manifest_path = app_dir / "goose-app-manifest.json" if manifest_path.exists(): try: with open(manifest_path, 'r') as f: manifest = json.load(f) app_info["manifest"] = manifest except json.JSONDecodeError: app_info["manifest_error"] = "Invalid goose-app-manifest.json file" apps.append(app_info) return { "success": True, "apps": apps, "count": len(apps), "app_dir": APP_DIR } except Exception as e: logger.error(f"Error listing apps: {e}") return {"success": False, "error": f"Failed to list apps: {str(e)}"} @mcp.tool() def app_delete(app_name: str) -> Dict[str, Any]: """ Delete an existing web application. Args: app_name: Name of the application to delete Returns: A dictionary containing the result of the operation """ try: # Find the app directory app_path = os.path.join(APP_DIR, app_name) if not os.path.exists(app_path): return { "success": False, "error": f"App '{app_name}' not found at {app_path}" } # Delete the app directory shutil.rmtree(app_path) return { "success": True, "app_name": app_name, "message": f"App '{app_name}' deleted successfully" } except Exception as e: logger.error(f"Error deleting app: {e}") return {"success": False, "error": f"Failed to delete app: {str(e)}"} @mcp.tool() def app_create(app_name: str, description: str = "") -> Dict[str, Any]: """ Create a new web application directory and copy starter files. The starter files are for you to replace with actual content, you don't have to use them as is. the goose_api.js file is a utility you will want to keep in case you need to do api calls as part of your app via goose. Args: app_name: Name of the application (will be used as directory name) description: Brief description of the application (default: "") Returns: A dictionary containing the result of the operation After this, consider how you want to change the app to meet the functionality, look at the examples in resources dir if you like. Or, you can replace the content with existing html/css/js files you have (just make sure to leave the goose_api.js file in the app dir) Use the app_error tool once it is opened and user has interacted (or has started) to check for errors you can correct the first time, this is important to know it works. """ global http_server, server_port if http_server: return "There is already a server running. Please stop it before creating a new app, or consider if an existing app should be modified instead." try: # Sanitize app name (replace spaces with hyphens, remove special characters) safe_app_name = "".join(c if c.isalnum() else "-" for c in app_name).lower() # Create app directory app_path = os.path.join(APP_DIR, safe_app_name) if os.path.exists(app_path): return { "success": False, "error": f"App '{safe_app_name}' already exists at {app_path}" } os.makedirs(app_path, exist_ok=True) # Copy kitchen-sink template files kitchen_sink_dir = os.path.join(RESOURCES_DIR, "kitchen-sink") copied_files = ["index.html", "style.css", "script.js", "goose_api.js"] for file_name in copied_files: src_file = os.path.join(kitchen_sink_dir, file_name) dest_file = os.path.join(app_path, file_name) shutil.copy2(src_file, dest_file) # Create manifest file manifest = { "name": app_name, "description": description, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "files": copied_files } with open(os.path.join(app_path, "goose-app-manifest.json"), 'w') as f: json.dump(manifest, f, indent=2) return { "success": True, "app_name": safe_app_name, "app_path": app_path, "files": copied_files, "message": f"App '{app_name}' created successfully at {app_path}" } except Exception as e: logger.error(f"Error creating app: {e}") return {"success": False, "error": f"Failed to create app: {str(e)}"} @mcp.tool() def app_serve(app_name: str) -> Dict[str, Any]: """ Serve an existing web application on a local HTTP server. The server will automatically find an available port. Can only serve one app at a time Args: app_name: Name of the application to serve Returns: A dictionary containing the result of the operation """ global http_server, server_port, app_response, response_ready if http_server: return "There is already a server running" # Reset response state app_response = None response_ready = False try: # Find the app directory app_path = os.path.join(APP_DIR, app_name) if not os.path.exists(app_path): return { "success": False, "error": f"App '{app_name}' not found at {app_path}" } # Stop any existing server if http_server: logger.info("Stopping existing HTTP server") http_server.shutdown() http_server.server_close() http_server = None # Find a free port import socket def find_free_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('', 0)) return s.getsockname()[1] # Try the default port first, if busy find a free one try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('', server_port)) except OSError: logger.info(f"Default port {server_port} is busy, finding a free port") server_port = find_free_port() logger.info(f"Found free port: {server_port}") # Create a custom handler that serves from the app directory # and replaces environment variables in JavaScript files class EnvAwareHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=app_path, **kwargs) def end_headers(self): # Add cache control headers to ALL responses self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') self.send_header('Pragma', 'no-cache') self.send_header('Expires', '0') super().end_headers() def do_GET(self): # Check if this is a wait_for_response request if self.path.startswith('/wait_for_response'): global app_response, response_lock, response_ready # Reset response state for a new request if self.path.startswith('/wait_for_response/reset'): with response_lock: app_response = None response_ready = False self.send_response(200) self.send_header('Content-type', 'application/json') self.end_headers() response_data = json.dumps({"success": True, "message": "Response state reset"}) self.wfile.write(response_data.encode('utf-8')) return # Check if response already exists if app_response is not None and response_ready: # Return the response immediately self.send_response(200) self.send_header('Content-type', 'application/json') self.end_headers() response_data = json.dumps({"success": True, "data": app_response}) self.wfile.write(response_data.encode('utf-8')) # Reset the response state after sending it with response_lock: app_response = None response_ready = False return # Wait for the response with timeout with response_lock: # Wait for up to 180 seconds for the response to be ready start_time = time.time() while not response_ready and time.time() - start_time < 180: response_lock.wait(180 - (time.time() - start_time)) # Check if the response is now available if response_ready and app_response is not None: break # Check if we got the response or timed out if response_ready and app_response is not None: self.send_response(200) self.send_header('Content-type', 'application/json') self.end_headers() response_data = json.dumps({"success": True, "data": app_response}) self.wfile.write(response_data.encode('utf-8')) else: # Timeout occurred self.send_response(408) # Request Timeout self.send_header('Content-type', 'application/json') self.end_headers() response_data = json.dumps({"success": False, "error": "Timeout waiting for response"}) self.wfile.write(response_data.encode('utf-8')) return # Get the file path path = self.translate_path(self.path) # Check if the file exists if os.path.isfile(path): # Check if it's a JavaScript file that might need variable replacement if path.endswith('.js'): try: with open(path, 'r') as f: content = f.read() # Check if the file contains environment variables that need to be replaced if '$GOOSE_PORT' in content or '$GOOSE_SERVER__SECRET_KEY' in content: # Replace environment variables goose_port = os.environ.get('GOOSE_PORT', '0') secret_key = os.environ.get('GOOSE_SERVER__SECRET_KEY', '') # Replace variables content = content.replace('$GOOSE_PORT', goose_port) content = content.replace('$GOOSE_SERVER__SECRET_KEY', secret_key) # Send the modified content self.send_response(200) self.send_header('Content-type', 'application/javascript') self.send_header('Content-Length', str(len(content))) self.end_headers() self.wfile.write(content.encode('utf-8')) return except Exception as e: logger.error(f"Error processing JavaScript file: {e}") # If we didn't handle it specially, use the default handler return super().do_GET() # Start the server in a separate thread import threading # Use a thread-safe event to signal when the server is ready server_ready = threading.Event() server_error = [None] # Use a list to store error from thread def run_server(): global http_server try: with socketserver.TCPServer(("", server_port), EnvAwareHandler) as server: http_server = server # Signal that server is ready server_ready.set() logger.info(f"Serving app '{app_name}' at http://localhost:{server_port}") logger.info(f"Using GOOSE_PORT={os.environ.get('GOOSE_PORT', '3000')}") logger.info(f"Using GOOSE_SERVER__SECRET_KEY={os.environ.get('GOOSE_SERVER__SECRET_KEY', '')[:5]}...") server.serve_forever() except Exception as e: server_error[0] = str(e) server_ready.set() # Signal even on error logger.error(f"Server error: {e}") server_thread = threading.Thread(target=run_server) server_thread.daemon = True server_thread.start() # Wait for the server to start or fail, with timeout if not server_ready.wait(timeout=2.0): return { "success": False, "error": "Server failed to start within timeout period" } # Check if there was an error if server_error[0]: return { "success": False, "error": f"Failed to serve app: {server_error[0]}" } return { "success": True, "app_name": app_name, "port": server_port, "url": f"http://localhost:{server_port}", "message": f"App '{app_name}' is now being served at http://localhost:{server_port}" } except Exception as e: logger.error(f"Error serving app: {e}") return {"success": False, "error": f"Failed to serve app: {str(e)}"} @mcp.tool() def app_stop_server() -> Dict[str, Any]: """ Stop the currently running HTTP server. Returns: A dictionary containing the result of the operation """ global http_server, app_response, response_ready try: if http_server: logger.info("Stopping HTTP server") http_server.shutdown() http_server.server_close() http_server = None # Reset response state app_response = None response_ready = False return { "success": True, "message": "HTTP server stopped successfully" } else: return { "success": False, "error": "No HTTP server is currently running" } except Exception as e: logger.error(f"Error stopping server: {e}") return {"success": False, "error": f"Failed to stop server: {str(e)}"} @mcp.tool() def app_open(app_name: str) -> Dict[str, Any]: """ Open an app in the default web browser. If the app is not currently being served, it will be served first. Can only open one app at a time. Args: app_name: Name of the application to open Returns: A dictionary containing the result of the operation """ global http_server try: # Find the app directory app_path = os.path.join(APP_DIR, app_name) if not os.path.exists(app_path): return { "success": False, "error": f"App '{app_name}' not found at {app_path}" } # If the server is not running, start it if not http_server: serve_result = app_serve(app_name) if not serve_result["success"]: return serve_result # Get the URL from the serve result url = serve_result["url"] else: # Use the current server port url = f"http://localhost:{server_port}" # Check if we're on macOS if os.uname().sysname == "Darwin": # macOS # Use Chrome in app mode chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" if os.path.exists(chrome_path): logger.info(f"Opening app in Chrome app mode: {url}") # Use Popen instead of run to avoid blocking subprocess.Popen([chrome_path, f"--app={url}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: # Fallback to default browser if Chrome is not installed logger.info(f"Chrome not found, opening in default browser: {url}") # Use Popen instead of run to avoid blocking subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: # For non-macOS systems, use the default browser # Use Popen instead of run to avoid blocking subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return { "success": True, "app_name": app_name, "url": url, "message": f"App '{app_name}' opened in browser at {url}" } except Exception as e: logger.error(f"Error opening app: {e}") return {"success": False, "error": f"Failed to open app: {str(e)}"} @mcp.tool() def app_refresh() -> Dict[str, Any]: """ Refresh the currently open app in Chrome. Only works on macOS with Google Chrome. Returns: A dictionary containing the result of the operation """ try: # Check if we're on macOS if os.uname().sysname != "Darwin": return { "success": False, "error": "This function is only available on macOS" } # Use AppleScript to refresh the active tab in Chrome refresh_script = 'tell application "Google Chrome" to tell active tab of front window to reload' # Use Popen instead of run to avoid blocking subprocess.Popen(["osascript", "-e", refresh_script], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return { "success": True, "message": "App refreshed successfully in Chrome" } except Exception as e: logger.error(f"Error refreshing app: {e}") return {"success": False, "error": f"Failed to refresh app: {str(e)}"} @mcp.tool() def app_response(string_data: str = None, list_data: List[str] = None, table_data: Dict[str, List] = None) -> bool: """ Use this to return a response to the app that has been requested. Provide only one of string_data, list_data, or table_data. Args: string_data: Optional string response list_data: Optional list of strings response table_data: Optional table response with columns and rows Format: {"columns": ["col1", "col2", ...], "rows": [["row1col1", "row1col2", ...], ...]} Returns: True if the response was stored successfully, False otherwise """ global app_response, response_lock, response_ready try: # Check that exactly one data type is provided provided_data = [d for d in [string_data, list_data, table_data] if d is not None] if len(provided_data) != 1: logger.error("Exactly one of string_data, list_data, or table_data must be provided") return False # Determine the type of data and store it if string_data is not None: data = string_data elif list_data is not None: data = list_data elif table_data is not None: # Validate table_data format if not isinstance(table_data, dict) or "columns" not in table_data or "rows" not in table_data: logger.error("Table data must have 'columns' and 'rows' keys") return False data = table_data # Store the response and notify waiting threads with response_lock: global app_response # Declare global inside the function block app_response = data global response_ready # Declare global inside the function block response_ready = True response_lock.notify_all() return True except Exception as e: logger.error(f"Error storing response: {e}") return False @mcp.tool() def app_error(error_message: str = None, clear = False) -> str: """ Report an error from the app or retrieve the list of errors. This is useful while developing or debugging the app as it allows errors (or any messages) to be reported and monitored Args: error_message: Optional error message to report. If None, returns the list of errors. clear: Optional, If True, clears the list of errors Returns: A string containing the list of errors if error_message is None, otherwise a confirmation message. """ global app_errors try: # If no error message is provided, return the list of errors if error_message is None: # if app errors is empty if not app_errors: return "No errors reported. If needed, consider adding in some calls to reportError() in your app code to help with debugging." # Format the errors as a numbered list error_list = "\n".join([f"{i+1}. {err}" for i, err in enumerate(app_errors)]) if clear: app_errors.clear() return f"Reported errors:\n{error_list}" if clear: app_errors.clear() # Add the error to the list with a timestamp timestamp = time.strftime("%Y-%m-%d %H:%M:%S") app_errors.append(f"[{timestamp}] {error_message}") # Keep only the last 100 errors to prevent unbounded growth if len(app_errors) > 100: app_errors = app_errors[-100:] logger.warning(f"App error reported: {error_message}") return f"Error reported: {error_message}" except Exception as e: logger.error(f"Error handling app_error: {e}") return f"Failed to process error: {str(e)}" def main(): """Entry point for the package when installed via pip.""" import sys # Check command line arguments if len(sys.argv) > 1: print(f"Unknown argument: {sys.argv[1]}") print("No command line arguments are supported") else: # Normal MCP server mode logger.info("Starting MCP server...") mcp.run() if __name__ == "__main__": main()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/michaelneale/goose-app-maker-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server