# 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())
```