#
tokens: 5541/50000 6/6 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![smithery badge](https://smithery.ai/badge/mcp-server-ancestry)](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())
```