# Directory Structure ``` ├── .gitignore ├── .python-version ├── LICENSE ├── pyproject.toml ├── README.md ├── src │ └── mcp_server_ancestry │ ├── __init__.py │ └── server.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.10 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Visual Studio extensions 10 | .vs 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 113 | .pdm.toml 114 | .pdm-python 115 | .pdm-build/ 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Ancestry MCP Server 2 | [](https://smithery.ai/server/mcp-server-ancestry) 3 | [![MIT licensed][mit-badge]][mit-url] 4 | [![Python Version][python-badge]][python-url] 5 | [![PyPI version][pypi-badge]][pypi-url] 6 | 7 | [mit-badge]: https://img.shields.io/pypi/l/mcp.svg 8 | [mit-url]: https://github.com/reeeeemo/ancestry-mcp/blob/main/LICENSE 9 | [python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg 10 | [python-url]: https://www.python.org/downloads/ 11 | [pypi-badge]: https://badge.fury.io/py/mcp-server-ancestry.svg 12 | [pypi-url]: https://pypi.org/project/mcp-server-ancestry 13 | 14 | Built on top of the [Model Context Protocol Python SDK](https://modelcontextprotocol.io) 15 | 16 | <a href="https://glama.ai/mcp/servers/pk5j4bp5nv"><img width="380" height="200" src="https://glama.ai/mcp/servers/pk5j4bp5nv/badge" alt="Ancestry MCP server" /></a> 17 | 18 | ## Overview 19 | 20 | Python server implementing Model Context Protocol (MCP) for interactibility with `.ged` files *(GEDCOM files, commonly seen on Ancestry.com)* 21 | 22 | ## Features 23 | 24 | - Read and parse .ged files 25 | - Rename `.ged` files 26 | - Search within .ged files for certain individuals, family, etc 27 | 28 | **Note:** The server will only allow operations within the directory specified via `args` 29 | 30 | ## Resources 31 | 32 | - `gedcom://{file_name}`: `.ged` operations interface 33 | 34 | ## Tools 35 | 36 | - **list_files** 37 | - List a (or multiple) `.ged` file within the directory 38 | - Input: `name` (string) 39 | 40 | - **rename_file** 41 | - Renames a (or multiple) `.ged` file within the directory 42 | - Inputs: 43 | - `file_name` (string): Old file name 44 | - `new_name` (string) 45 | 46 | - **view_file** 47 | - Parses and reads full contents of a `.ged` file 48 | - Can also parse and read multiple files 49 | - Can get specific information out of file(s), such as date of birth, marriage, etc. 50 | - Input: `name` (string) 51 | 52 | 53 | ## Usage with Claude Desktop 54 | 55 | ### Installing via Smithery 56 | 57 | To install Ancestry GEDCOM Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-server-ancestry): 58 | 59 | ```bash 60 | npx -y @smithery/cli install mcp-server-ancestry --client claude 61 | ``` 62 | 63 | ### Installing Manually 64 | 1. First, install the package: 65 | ```pip install mcp-server-ancestry``` 66 | 67 | 68 | 2. Add this to your `claude_desktop_config.json` 69 | 70 | ```json 71 | { 72 | "mcpServers": { 73 | "ancestry": { 74 | "command": "mcp-server-ancestry", 75 | "args": ["--gedcom-path", "path/to/your/gedcom/files"] 76 | } 77 | } 78 | } 79 | ``` 80 | 81 | ## License 82 | 83 | This project is licensed under the MIT License - see the LICENSE file for details. 84 | ``` -------------------------------------------------------------------------------- /src/mcp_server_ancestry/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from .server import serve 2 | 3 | 4 | def main(): 5 | """MCP Ancestry Server - Takes GEDCOM files and provides functionality""" 6 | import asyncio 7 | import argparse 8 | 9 | parser = argparse.ArgumentParser( 10 | description='give a model the ability to use GEDCOM files' 11 | ) 12 | parser.add_argument( 13 | '--gedcom-path', 14 | type=str, 15 | required=True, 16 | help='Path to directory containing GEDCOM files' 17 | ) 18 | 19 | args = parser.parse_args() 20 | 21 | asyncio.run(serve(args.gedcom_path)) 22 | 23 | if __name__ == "__main__": 24 | main() ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "mcp-server-ancestry" 3 | version = "0.1.1" 4 | description = "A Model Context Protocol server providing functionality to GEDCOM files via LLM usage" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [{ name = "Robert Oxley" }] 8 | maintainers = [{ name = "Robert Oxley", email = "[email protected]" }] 9 | keywords = ["mcp", "llm", "automation"] 10 | license = { text = "MIT" } 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | ] 18 | dependencies = [ 19 | "mcp>=1.0.0", 20 | "pydantic>=2.0.0", 21 | "requests>=2.32.3", 22 | "chardet>=5.2.0", 23 | ] 24 | 25 | [project.scripts] 26 | mcp-server-ancestry = "mcp_server_ancestry:main" 27 | 28 | [build-system] 29 | requires = ["hatchling"] 30 | build-backend = "hatchling.build" 31 | 32 | [tool.uv] 33 | dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3"] ``` -------------------------------------------------------------------------------- /src/mcp_server_ancestry/server.py: -------------------------------------------------------------------------------- ```python 1 | from ast import Dict 2 | import asyncio 3 | import logging 4 | import json 5 | import os 6 | from pathlib import Path 7 | import mcp.types as types 8 | from mcp.server import Server 9 | from mcp.server.stdio import stdio_server 10 | from enum import Enum 11 | from pydantic import BaseModel 12 | import chardet 13 | 14 | ged_level_1_tags = ['BIRT', 'DEAT', 'MARR', 'BURI', 'DIV', 'OCCU', 'RESI', 'CHR'] 15 | 16 | # Tools schemas 17 | class ListFiles(BaseModel): 18 | name: str 19 | 20 | class RenameFiles(BaseModel): 21 | file_name: str 22 | new_name: str 23 | 24 | class ViewFiles(BaseModel): 25 | name: str 26 | 27 | # Tool names 28 | class AncestryTools(str, Enum): 29 | LIST_FILES = "list_files" 30 | RENAME_FILE = "rename_file" 31 | VIEW_FILES = "view_file" 32 | 33 | # Tool helper functions 34 | def find_files_with_name(name: str | None = None, path: Path | None = None) -> list[Path]: 35 | pattern = f"{name}.ged" if name is not None else "*.ged" 36 | return list(path.glob(pattern)) 37 | 38 | def rename_files(new_name: str | None = None, files: list[Path] | None = None) -> tuple[str, list[Dict], str]: 39 | try: 40 | renamed_files = [] 41 | for file in files: 42 | try: 43 | new_path = file.parent / f"{new_name.removesuffix('.ged')}.ged" 44 | if new_path.exists(): 45 | return [], f"Cannot rename, {new_path.name} already exists" 46 | file.rename(new_path) 47 | renamed_files.append(new_path) 48 | except PermissionError: 49 | return [], f'Permission denied: Cannot rename {file.name}. Check write perms' 50 | except OSError as e: 51 | return [], f'Error renaming {file.name}: {str(e)}' 52 | except Exception as e: 53 | return [], f'An unexpected error ocurred: {str(e)}. Please try again later or contact support.' 54 | 55 | return renamed_files, "" 56 | 57 | def parse_ged_file(files: list[Path] | None = None) -> tuple[list[Dict], str]: 58 | try: 59 | parsed_geds = {} 60 | for file in files: 61 | if not file.exists() or file.suffix.lower() != '.ged': 62 | continue 63 | 64 | parsed_geds[file.name] = [] 65 | 66 | # determine encoding 67 | raw_bytes = file.read_bytes() 68 | result = chardet.detect(raw_bytes) 69 | # open file, and parse ged data 70 | try: 71 | with file.open(encoding=result['encoding']) as ged: 72 | ged_obj = {} 73 | cur_lvl1_tag = None 74 | 75 | for line in ged: 76 | ''' 77 | Level 0: root records 78 | Level 1: main info about records 79 | Level 2: details about level 1 info 80 | ''' 81 | parts = line.strip().split(' ', 2) 82 | if not parts: 83 | continue 84 | level = int(parts[0]) 85 | tag = parts[1] 86 | value = parts[2] if len(parts) > 2 else '' 87 | 88 | if level == 0: 89 | # save prev obj if exists 90 | if ged_obj and 'type' in ged_obj: 91 | parsed_geds[file.name].append(ged_obj) 92 | 93 | ged_obj = {} 94 | if '@' in tag: # ID 95 | ged_obj['id'] = tag 96 | ged_obj['type'] = value 97 | elif level == 1: 98 | cur_lvl1_tag = tag 99 | if tag in ged_level_1_tags: 100 | ged_obj[tag] = {} 101 | else: 102 | ged_obj[tag] = value 103 | elif level == 2 and cur_lvl1_tag: 104 | # If parent is an event 105 | if cur_lvl1_tag in ged_level_1_tags: 106 | if cur_lvl1_tag not in ged_obj: 107 | ged_obj[cur_lvl1_tag] = {} 108 | ged_obj[cur_lvl1_tag][tag] = value 109 | elif cur_lvl1_tag == 'NAME': 110 | ged_obj[f'NAME_{tag}'] = value 111 | else: 112 | ged_obj[tag] = value 113 | 114 | if ged_obj and 'type' in ged_obj: 115 | parsed_geds[file.name].append(ged_obj) 116 | except UnicodeDecodeError: 117 | return [], f'File could not be decoded, please check encoding on the .ged' 118 | except Exception as e: 119 | return [], f'An unexpected error occured: {str(e)}. Please try again later or contact support.' 120 | return parsed_geds, "" 121 | 122 | # logging config 123 | logging.basicConfig( 124 | filename='mcp_ancestry.log', 125 | level=logging.DEBUG, 126 | format='%(asctime)s - %(levelname)s - %(message)s' 127 | 128 | ) 129 | 130 | # server main code 131 | async def serve(gedcom_path: str | None = None) -> None: 132 | app = Server("ancestry") 133 | 134 | # Verification of GEDCOM path 135 | path = Path(gedcom_path) 136 | if not path.exists(): 137 | raise ValueError(f'Invalid path: {gedcom_path}') 138 | if not path.is_dir(): 139 | raise ValueError(f'GEDCOM path is not a directory: {gedcom_path}') 140 | 141 | if not os.access(path, os.R_OK): 142 | raise ValueError(f'GEDCOM path does not have read / write permissions: {gedcom_path}') 143 | 144 | # debug stuff ! 145 | logging.debug(f'Path exists and is valid: {path.absolute()}') 146 | logging.debug(f'Contents of directory: {list(path.iterdir())}') 147 | 148 | # makes GEDCOM files visible to Claude 149 | @app.list_resources() 150 | async def list_resources() -> list[types.Resource]: 151 | gedcom_files = list(path.glob("*.ged")) 152 | # scan gedcom path dir for .ged files 153 | return [ 154 | types.Resource( 155 | uri=f"gedcom://{file.name}", 156 | name=file.name, 157 | mimeType="application/x-gedcom" 158 | ) 159 | for file in gedcom_files 160 | ] 161 | 162 | 163 | @app.list_tools() 164 | async def list_tools() -> list[types.Tool]: 165 | return [ 166 | types.Tool( 167 | name=AncestryTools.LIST_FILES, 168 | description="List GEDCOM files", 169 | inputSchema=ListFiles.model_json_schema() 170 | ), 171 | types.Tool( 172 | name=AncestryTools.RENAME_FILE, 173 | description="Rename a GEDCOM file", 174 | inputSchema=RenameFiles.model_json_schema() 175 | ), 176 | types.Tool( 177 | name=AncestryTools.VIEW_FILES, 178 | description="View a GEDCOM file in plaintext format", 179 | inputSchema=ViewFiles.model_json_schema() 180 | ) 181 | ] 182 | 183 | @app.call_tool() 184 | async def call_tool(name: str, 185 | arguments: dict) -> list[types.TextContent]: 186 | match name: 187 | case AncestryTools.LIST_FILES: 188 | gedcom_files = find_files_with_name(arguments["name"].removesuffix('.ged'), path) 189 | return [ 190 | types.TextContent( 191 | type="text", 192 | text=f"File: {file.name}\nSize: {file.stat().st_size} bytes\nURI: gedcom://{file.name}" 193 | ) 194 | for file in gedcom_files 195 | ] 196 | case AncestryTools.RENAME_FILE: 197 | # get files, if none found tell server that 198 | gedcom_files = find_files_with_name(arguments["file_name"].removesuffix('.ged'), path) 199 | if not gedcom_files: 200 | return [ 201 | types.TextContent( 202 | type="text", 203 | text=f'No files found matching {arguments["file_name"]}' 204 | ) 205 | ] 206 | # rename files, if error message tell server 207 | renamed_files, message = rename_files(arguments["new_name"].removesuffix('.ged'), gedcom_files) 208 | if message: 209 | return [ 210 | types.TextContent( 211 | type="text", 212 | text=message 213 | ) 214 | ] 215 | 216 | return [ 217 | types.TextContent( 218 | type="text", 219 | text=f"{file.name}\nURI:gedcom://{file.name}" 220 | ) 221 | for file in renamed_files 222 | ] 223 | case AncestryTools.VIEW_FILES: 224 | # get files, if none found tell serve rthat 225 | gedcom_files = find_files_with_name(arguments["name"].removesuffix('.ged'), path) 226 | if not gedcom_files: 227 | return [ 228 | types.TextContent( 229 | type="text", 230 | text=f'No files found matching {arguments["name"]}' 231 | ) 232 | ] 233 | 234 | # show file, if error message tell server 235 | parsed_geds, message = parse_ged_file(gedcom_files) 236 | 237 | if message: 238 | return [ 239 | types.TextContent( 240 | type="text", 241 | text=message 242 | ) 243 | ] 244 | 245 | return [ 246 | types.TextContent( 247 | type="text", 248 | text=json.dumps({filename: data}, indent=2) 249 | ) 250 | for filename, data in parsed_geds.items() 251 | ] 252 | case _: 253 | raise ValueError(f"Unknown Tool: {name}") 254 | 255 | 256 | async with stdio_server() as streams: 257 | await app.run( 258 | streams[0], 259 | streams[1], 260 | app.create_initialization_options() 261 | ) 262 | 263 | if __name__ == "__main__": 264 | asyncio.run(serve()) ```