Skip to content

Commit 782af01

Browse files
committed
feat: initial version of databutton-app-mcp
1 parent f41b37e commit 782af01

File tree

8 files changed

+259
-173
lines changed

8 files changed

+259
-173
lines changed

.gitignore

Lines changed: 7 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,174 +1,9 @@
1-
# Byte-compiled / optimized / DLL files
2-
__pycache__/
3-
*.py[cod]
4-
*$py.class
1+
*.local
2+
*.env
53

6-
# C extensions
7-
*.so
4+
**/.venv
5+
**/venv
6+
**/venvs
87

9-
# Distribution / packaging
10-
.Python
11-
build/
12-
develop-eggs/
13-
dist/
14-
downloads/
15-
eggs/
16-
.eggs/
17-
lib/
18-
lib64/
19-
parts/
20-
sdist/
21-
var/
22-
wheels/
23-
share/python-wheels/
24-
*.egg-info/
25-
.installed.cfg
26-
*.egg
27-
MANIFEST
28-
29-
# PyInstaller
30-
# Usually these files are written by a python script from a template
31-
# before PyInstaller builds the exe, so as to inject date/other infos into it.
32-
*.manifest
33-
*.spec
34-
35-
# Installer logs
36-
pip-log.txt
37-
pip-delete-this-directory.txt
38-
39-
# Unit test / coverage reports
40-
htmlcov/
41-
.tox/
42-
.nox/
43-
.coverage
44-
.coverage.*
45-
.cache
46-
nosetests.xml
47-
coverage.xml
48-
*.cover
49-
*.py,cover
50-
.hypothesis/
51-
.pytest_cache/
52-
cover/
53-
54-
# Translations
55-
*.mo
56-
*.pot
57-
58-
# Django stuff:
59-
*.log
60-
local_settings.py
61-
db.sqlite3
62-
db.sqlite3-journal
63-
64-
# Flask stuff:
65-
instance/
66-
.webassets-cache
67-
68-
# Scrapy stuff:
69-
.scrapy
70-
71-
# Sphinx documentation
72-
docs/_build/
73-
74-
# PyBuilder
75-
.pybuilder/
76-
target/
77-
78-
# Jupyter Notebook
79-
.ipynb_checkpoints
80-
81-
# IPython
82-
profile_default/
83-
ipython_config.py
84-
85-
# pyenv
86-
# For a library or package, you might want to ignore these files since the code is
87-
# intended to run in multiple environments; otherwise, check them in:
88-
# .python-version
89-
90-
# pipenv
91-
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92-
# However, in case of collaboration, if having platform-specific dependencies or dependencies
93-
# having no cross-platform support, pipenv may install dependencies that don't work, or not
94-
# install all needed dependencies.
95-
#Pipfile.lock
96-
97-
# UV
98-
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99-
# This is especially recommended for binary packages to ensure reproducibility, and is more
100-
# commonly ignored for libraries.
101-
#uv.lock
102-
103-
# poetry
104-
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105-
# This is especially recommended for binary packages to ensure reproducibility, and is more
106-
# commonly ignored for libraries.
107-
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108-
#poetry.lock
109-
110-
# pdm
111-
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112-
#pdm.lock
113-
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114-
# in version control.
115-
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116-
.pdm.toml
117-
.pdm-python
118-
.pdm-build/
119-
120-
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121-
__pypackages__/
122-
123-
# Celery stuff
124-
celerybeat-schedule
125-
celerybeat.pid
126-
127-
# SageMath parsed files
128-
*.sage.py
129-
130-
# Environments
131-
.env
132-
.venv
133-
env/
134-
venv/
135-
ENV/
136-
env.bak/
137-
venv.bak/
138-
139-
# Spyder project settings
140-
.spyderproject
141-
.spyproject
142-
143-
# Rope project settings
144-
.ropeproject
145-
146-
# mkdocs documentation
147-
/site
148-
149-
# mypy
150-
.mypy_cache/
151-
.dmypy.json
152-
dmypy.json
153-
154-
# Pyre type checker
155-
.pyre/
156-
157-
# pytype static type analyzer
158-
.pytype/
159-
160-
# Cython debug symbols
161-
cython_debug/
162-
163-
# PyCharm
164-
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165-
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166-
# and can be added to the global gitignore or merged into this file. For a more nuclear
167-
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
168-
#.idea/
169-
170-
# Ruff stuff:
171-
.ruff_cache/
172-
173-
# PyPI configuration file
174-
.pypirc
8+
**/__pycache__
9+
*.pyc

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2025 databutton
3+
Copyright (c) 2025 Databutton AS
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Databutton App MCP
2+
3+
Use API endpoints from your Databutton app as LLM tools from any MCP compatible client!
4+
5+
This is a simple proxy that runs locally and connects securely to your Databutton app
6+
using the MCP protocol over websockets.
7+
8+
First download an API key from the settings page of your Databutton app, and save it to a file.
9+
10+
For example say you downloaded a key file named `MY-DATABUTTON-APP-KEYID.json`,
11+
and save it to the directory `~/.config/databutton/mcp-keys/`.
12+
13+
Then to add this app to clients such as Claude Desktop, add the following to your client MCP settings or config file:
14+
15+
```json
16+
{
17+
"mcpServers": {
18+
"my-databutton-app": {
19+
"command": "uvx",
20+
"args": [
21+
"databutton-app-mcp"
22+
],
23+
"env": {
24+
"DATABUTTON_MCP_API_KEY": "~/.config/databutton/mcp-keys/MY-DATABUTTON-APP-KEYID.json"
25+
}
26+
}
27+
}
28+
}
29+
```
30+
31+
Here DATABUTTON_MCP_API_KEY either refers to the full path of the api key file you stored,
32+
or it can be the api key value itself.
33+
Either way one api key gives access to endpoints of one Databutton app.

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "databutton-app-mcp"
3+
version = "0.1.0"
4+
description = "Call your Databutton app endpoints as LLM tools with MCP"
5+
readme = "README.md"
6+
license = { "file" = "LICENSE" }
7+
classifiers = ["Development Status :: 3 - Alpha"]
8+
requires-python = ">=3.11"
9+
dependencies = ["websockets>=15.0.1"]
10+
11+
[project.urls]
12+
"Homepage" = "https://databutton.com"
13+
"Github" = "https://github.com/databutton/databutton-app-mcp"
14+
15+
[build-system]
16+
requires = ["hatchling"]
17+
build-backend = "hatchling.build"
18+
19+
[tool.hatch.version]
20+
path = "src/databutton_app_mcp/__init__.py"
21+
22+
[tool.hatch.build.targets.sdist]
23+
include = ["src/databutton_app_mcp"]

src/databutton_app_mcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "x.y.z"

src/databutton_app_mcp/__main__.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import argparse
2+
import base64
3+
import json
4+
import asyncio
5+
import signal
6+
import sys
7+
import os
8+
9+
from websockets import Subprotocol, connect
10+
from websockets.asyncio.client import ClientConnection
11+
12+
13+
async def stdin_to_ws(websocket: ClientConnection):
14+
"""Read from stdin and send to websocket"""
15+
loop = asyncio.get_event_loop()
16+
while True:
17+
line = await loop.run_in_executor(None, sys.stdin.readline)
18+
if not line: # EOF
19+
break
20+
await websocket.send(line.rstrip("\n"))
21+
22+
23+
async def ws_to_stdout(websocket: ClientConnection):
24+
"""Receive from websocket and write to stdout"""
25+
async for msg in websocket:
26+
print(msg, flush=True)
27+
28+
29+
async def run_ws_proxy(uri: str, bearer: str | None = None):
30+
# Set up signal handling for graceful exit
31+
loop = asyncio.get_event_loop()
32+
loop.add_signal_handler(signal.SIGINT, loop.stop)
33+
34+
auth_headers: list[tuple[str, str]] = []
35+
if bearer:
36+
auth_headers.append(("Authorization", f"Bearer {bearer}"))
37+
38+
auth_subprotocols: list[Subprotocol] = []
39+
# auth_subprotocols.append(Subprotocol(f"Authorization.Bearer.{bearer}"))
40+
41+
async with connect(
42+
uri,
43+
subprotocols=[Subprotocol("mcp")] + auth_subprotocols,
44+
additional_headers=auth_headers,
45+
) as websocket:
46+
stdin_task = asyncio.create_task(stdin_to_ws(websocket))
47+
stdout_task = asyncio.create_task(ws_to_stdout(websocket))
48+
49+
try:
50+
await asyncio.gather(stdin_task, stdout_task)
51+
except asyncio.CancelledError:
52+
print("Connection terminated", file=sys.stderr)
53+
finally:
54+
stdin_task.cancel()
55+
stdout_task.cancel()
56+
57+
58+
def parse_apikey(apikey: str) -> dict[str, str]:
59+
if not apikey:
60+
raise ValueError("API key must be provided")
61+
62+
try:
63+
return json.loads(base64.urlsafe_b64decode(apikey))
64+
except Exception:
65+
pass
66+
67+
try:
68+
return json.loads(base64.b64decode(apikey))
69+
except Exception:
70+
pass
71+
72+
try:
73+
return json.loads(apikey)
74+
except Exception:
75+
pass
76+
77+
raise ValueError("Invalid API key")
78+
79+
80+
def main():
81+
parser = argparse.ArgumentParser(
82+
description="Expose Databutton app endpoints as LLM tools with MCP over websocket"
83+
)
84+
parser.add_argument(
85+
"-k",
86+
"--apikeyfile",
87+
dest="apikeyfile",
88+
type=str,
89+
help="File containing API key to use",
90+
required=False,
91+
)
92+
93+
args = parser.parse_args()
94+
95+
claims: dict[str, str] = {}
96+
97+
if env_apikey := os.environ.get("DATABUTTON_API_KEY"):
98+
try:
99+
claims = parse_apikey(env_apikey)
100+
except Exception:
101+
with open(env_apikey, "r") as f:
102+
claims = parse_apikey(f.read().strip())
103+
elif args.apikeyfile:
104+
claims = parse_apikey(args.apikeyfile)
105+
else:
106+
print("No API key provided")
107+
sys.exit(1)
108+
109+
uri = claims.get("uri")
110+
if not uri:
111+
print("URI must be provided")
112+
sys.exit(1)
113+
if not (
114+
uri.startswith("ws://localhost")
115+
or uri.startswith("ws://127.0.0.1:")
116+
or uri.startswith("wss://")
117+
):
118+
print("URI must start with 'ws://' or 'wss://'")
119+
sys.exit(1)
120+
121+
# TODO: Exchange refresh token for access token here
122+
accessToken: str | None = claims.get("accessToken")
123+
if claims.get("refreshToken"):
124+
pass
125+
126+
try:
127+
asyncio.run(
128+
run_ws_proxy(
129+
uri=uri,
130+
bearer=accessToken,
131+
)
132+
)
133+
except KeyboardInterrupt:
134+
print("Program terminated", file=sys.stderr)
135+
136+
137+
if __name__ == "__main__":
138+
main()

0 commit comments

Comments
 (0)