tungsten-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tungsten_mcp-0.1.0/.gitignore +13 -0
- tungsten_mcp-0.1.0/PKG-INFO +96 -0
- tungsten_mcp-0.1.0/README.md +83 -0
- tungsten_mcp-0.1.0/pyproject.toml +21 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/__init__.py +174 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/models.py +80 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/oauth.py +380 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/resources.py +116 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/server.py +191 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/templates/tungsten_mcp/authorize.html +20 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/templates/tungsten_mcp/guide.html +81 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/templates/tungsten_mcp/macros.html +39 -0
- tungsten_mcp-0.1.0/src/tungsten_mcp/tools.py +398 -0
- tungsten_mcp-0.1.0/tests/conftest.py +209 -0
- tungsten_mcp-0.1.0/tests/test_mcp.py +201 -0
- tungsten_mcp-0.1.0/tests/test_oauth.py +198 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tungsten-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tungsten plugin: an MCP server so AI assistants like Claude can read and change your admin panel's data.
|
|
5
|
+
Project-URL: Homepage, https://tungsten.prisminfoways.com/
|
|
6
|
+
Project-URL: Repository, https://github.com/Prism-Infoways/Tungsten
|
|
7
|
+
Author: Prism Infoways
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai,claude,fastapi,llm,mcp,model context protocol,tungsten,tungsten-admin
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: tungsten-admin>=0.1.4
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# tungsten-mcp
|
|
15
|
+
|
|
16
|
+
Let AI assistants like Claude work with your [Tungsten](https://tungsten.prisminfoways.com/) panel, through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP).
|
|
17
|
+
|
|
18
|
+
Ask things like *"Show the 10 newest orders"*, *"How many products are out of stock?"* or *"Mark order 1042 as shipped"*, and the AI does it with your panel's data.
|
|
19
|
+
|
|
20
|
+
## What you get
|
|
21
|
+
|
|
22
|
+
- An MCP server inside your panel, at `/admin/mcp`.
|
|
23
|
+
- Tools for every resource: `list_resources`, `describe_resource`, `list_records` (search, filters, sort, pages), `get_record`, and with a token that allows changes, `create_record`, `update_record`, `delete_record`.
|
|
24
|
+
- Login with OAuth: in Claude, add the server URL as a custom connector, press Connect, log in to your panel and press Allow. No token to copy.
|
|
25
|
+
- An **AI access (MCP)** screen to see and revoke connected apps and tokens, with a **How to connect** guide for Claude, Claude Code, Claude Desktop and other apps.
|
|
26
|
+
|
|
27
|
+
It is safe by default:
|
|
28
|
+
|
|
29
|
+
- Each token acts as the user who made it. Their roles, policies and tenancy apply, as in the panel.
|
|
30
|
+
- Writes go through the resource's form: the same validation, defaults and hooks, and they show in the activity log.
|
|
31
|
+
- Tokens are read-only unless you switch on **Allow changes**. Only a hash of each token is stored.
|
|
32
|
+
- Password hashes, tokens, secrets and API keys are never sent.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install tungsten-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from tungsten_mcp import McpPlugin
|
|
42
|
+
|
|
43
|
+
panel = Panel(..., app_url="https://admin.example.com")
|
|
44
|
+
panel.plugin(McpPlugin())
|
|
45
|
+
panel.create_tables(engine)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then open **AI access (MCP)** in the panel and press **How to connect**. Needs `tungsten-admin` 0.1.4 or newer.
|
|
49
|
+
|
|
50
|
+
## Connect
|
|
51
|
+
|
|
52
|
+
**Claude (web, desktop, phone)**: Settings, Connectors, Add custom connector, paste `https://admin.example.com/admin/mcp`, then Connect. Log in to the panel and press Allow.
|
|
53
|
+
|
|
54
|
+
**Claude Code** with login: `claude mcp add --transport http tungsten https://admin.example.com/admin/mcp`, then `/mcp` in Claude Code.
|
|
55
|
+
|
|
56
|
+
Apps without MCP login use a token: press **New token** on the AI access (MCP) screen.
|
|
57
|
+
|
|
58
|
+
**Claude Code** with a token
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
claude mcp add --transport http tungsten https://admin.example.com/admin/mcp --header "Authorization: Bearer YOUR_TOKEN"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Claude Desktop** (Settings, Developer, Edit Config; needs Node.js)
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"tungsten": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["-y", "mcp-remote", "https://admin.example.com/admin/mcp", "--header", "Authorization:${AUTH_HEADER}"],
|
|
72
|
+
"env": {"AUTH_HEADER": "Bearer YOUR_TOKEN"}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Cursor, VS Code and others**: a Streamable HTTP server with the URL and an `Authorization: Bearer YOUR_TOKEN` header.
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
McpPlugin(
|
|
84
|
+
path="/mcp", # where the server answers, under the panel
|
|
85
|
+
read_only=False, # True: no create, update or delete for any token
|
|
86
|
+
resources=None, # only these resources (slugs or classes)
|
|
87
|
+
exclude=["users"], # leave these out
|
|
88
|
+
hidden_fields=["customers.phone", "notes"], # never send these columns
|
|
89
|
+
max_limit=100, # most records per list_records call
|
|
90
|
+
name="Shop admin", # the name the AI app shows
|
|
91
|
+
oauth=True, # apps can connect by logging in (needs a panel with login)
|
|
92
|
+
token_minutes=60, # how long an OAuth access token works
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
With [multi-tenancy](https://tungsten.prisminfoways.com/docs/multi-tenancy.html), the token's user works in their first tenant. Send an `X-Tenant: <id>` header to pick another.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# tungsten-mcp
|
|
2
|
+
|
|
3
|
+
Let AI assistants like Claude work with your [Tungsten](https://tungsten.prisminfoways.com/) panel, through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP).
|
|
4
|
+
|
|
5
|
+
Ask things like *"Show the 10 newest orders"*, *"How many products are out of stock?"* or *"Mark order 1042 as shipped"*, and the AI does it with your panel's data.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- An MCP server inside your panel, at `/admin/mcp`.
|
|
10
|
+
- Tools for every resource: `list_resources`, `describe_resource`, `list_records` (search, filters, sort, pages), `get_record`, and with a token that allows changes, `create_record`, `update_record`, `delete_record`.
|
|
11
|
+
- Login with OAuth: in Claude, add the server URL as a custom connector, press Connect, log in to your panel and press Allow. No token to copy.
|
|
12
|
+
- An **AI access (MCP)** screen to see and revoke connected apps and tokens, with a **How to connect** guide for Claude, Claude Code, Claude Desktop and other apps.
|
|
13
|
+
|
|
14
|
+
It is safe by default:
|
|
15
|
+
|
|
16
|
+
- Each token acts as the user who made it. Their roles, policies and tenancy apply, as in the panel.
|
|
17
|
+
- Writes go through the resource's form: the same validation, defaults and hooks, and they show in the activity log.
|
|
18
|
+
- Tokens are read-only unless you switch on **Allow changes**. Only a hash of each token is stored.
|
|
19
|
+
- Password hashes, tokens, secrets and API keys are never sent.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tungsten-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from tungsten_mcp import McpPlugin
|
|
29
|
+
|
|
30
|
+
panel = Panel(..., app_url="https://admin.example.com")
|
|
31
|
+
panel.plugin(McpPlugin())
|
|
32
|
+
panel.create_tables(engine)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then open **AI access (MCP)** in the panel and press **How to connect**. Needs `tungsten-admin` 0.1.4 or newer.
|
|
36
|
+
|
|
37
|
+
## Connect
|
|
38
|
+
|
|
39
|
+
**Claude (web, desktop, phone)**: Settings, Connectors, Add custom connector, paste `https://admin.example.com/admin/mcp`, then Connect. Log in to the panel and press Allow.
|
|
40
|
+
|
|
41
|
+
**Claude Code** with login: `claude mcp add --transport http tungsten https://admin.example.com/admin/mcp`, then `/mcp` in Claude Code.
|
|
42
|
+
|
|
43
|
+
Apps without MCP login use a token: press **New token** on the AI access (MCP) screen.
|
|
44
|
+
|
|
45
|
+
**Claude Code** with a token
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude mcp add --transport http tungsten https://admin.example.com/admin/mcp --header "Authorization: Bearer YOUR_TOKEN"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Claude Desktop** (Settings, Developer, Edit Config; needs Node.js)
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"tungsten": {
|
|
57
|
+
"command": "npx",
|
|
58
|
+
"args": ["-y", "mcp-remote", "https://admin.example.com/admin/mcp", "--header", "Authorization:${AUTH_HEADER}"],
|
|
59
|
+
"env": {"AUTH_HEADER": "Bearer YOUR_TOKEN"}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Cursor, VS Code and others**: a Streamable HTTP server with the URL and an `Authorization: Bearer YOUR_TOKEN` header.
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
McpPlugin(
|
|
71
|
+
path="/mcp", # where the server answers, under the panel
|
|
72
|
+
read_only=False, # True: no create, update or delete for any token
|
|
73
|
+
resources=None, # only these resources (slugs or classes)
|
|
74
|
+
exclude=["users"], # leave these out
|
|
75
|
+
hidden_fields=["customers.phone", "notes"], # never send these columns
|
|
76
|
+
max_limit=100, # most records per list_records call
|
|
77
|
+
name="Shop admin", # the name the AI app shows
|
|
78
|
+
oauth=True, # apps can connect by logging in (needs a panel with login)
|
|
79
|
+
token_minutes=60, # how long an OAuth access token works
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
With [multi-tenancy](https://tungsten.prisminfoways.com/docs/multi-tenancy.html), the token's user works in their first tenant. Send an `X-Tenant: <id>` header to pick another.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tungsten-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tungsten plugin: an MCP server so AI assistants like Claude can read and change your admin panel's data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Prism Infoways" }]
|
|
13
|
+
keywords = ["tungsten", "tungsten-admin", "mcp", "model context protocol", "claude", "ai", "llm", "fastapi"]
|
|
14
|
+
dependencies = ["tungsten-admin>=0.1.4"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://tungsten.prisminfoways.com/"
|
|
18
|
+
Repository = "https://github.com/Prism-Infoways/Tungsten"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/tungsten_mcp"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Let AI assistants (Claude and other MCP apps) work with your Tungsten panel.
|
|
2
|
+
|
|
3
|
+
from tungsten_mcp import McpPlugin
|
|
4
|
+
|
|
5
|
+
panel.plugin(McpPlugin())
|
|
6
|
+
panel.create_tables(engine)
|
|
7
|
+
|
|
8
|
+
Open "AI access (MCP)" in the panel, make a token, and press "How to connect". The panel's
|
|
9
|
+
resources then show up in the AI app as tools: list, search, read, and (with a token that
|
|
10
|
+
allows changes) create, update and delete records. Every call runs as the token's user, so
|
|
11
|
+
their roles, policies and tenancy apply, and writes go through the resource's form.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import Request
|
|
21
|
+
from fastapi.responses import JSONResponse
|
|
22
|
+
|
|
23
|
+
from tungsten import Plugin
|
|
24
|
+
|
|
25
|
+
from .models import McpBase, McpOAuthClient, McpOAuthCode, McpToken, hash_token, new_token
|
|
26
|
+
from .oauth import AuthorizePage, OAuth
|
|
27
|
+
from .resources import McpTokenResource
|
|
28
|
+
from .server import McpServer
|
|
29
|
+
from .tools import SENSITIVE, ToolError, Tools
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
DEFAULT_INSTRUCTIONS = (
|
|
34
|
+
"These tools work with the records of a Tungsten admin panel. Start with list_resources, then "
|
|
35
|
+
"describe_resource to see a resource's columns and form fields. list_records finds records "
|
|
36
|
+
"(search, filters, sort, paging); get_record reads one. Each record has _id, _title and a _url "
|
|
37
|
+
"link to it in the panel."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class McpPlugin(Plugin):
|
|
42
|
+
"""``McpPlugin(path="/mcp", read_only=False, resources=None, exclude=(), hidden_fields=(), max_limit=100)``.
|
|
43
|
+
|
|
44
|
+
- ``path``: where the MCP server answers, under the panel (``/admin/mcp``).
|
|
45
|
+
- ``read_only``: no create, update or delete tools for any token.
|
|
46
|
+
- ``resources``: only these resources (slugs or classes). Default: all of them.
|
|
47
|
+
- ``exclude``: resources to leave out (slugs or classes).
|
|
48
|
+
- ``hidden_fields``: columns never sent, as ``"email"`` (every resource) or ``"customers.email"``.
|
|
49
|
+
Passwords, tokens, secrets and API keys are always hidden.
|
|
50
|
+
- ``max_limit``: most records one ``list_records`` call returns.
|
|
51
|
+
- ``name``/``instructions``: what the AI app is told about this server.
|
|
52
|
+
- ``public_url``: this site's address for the URL in "How to connect". Default: the panel's
|
|
53
|
+
``app_url``, then the address in the browser.
|
|
54
|
+
- ``oauth``: let apps connect by logging in (OAuth 2.1 with PKCE), besides tokens made on
|
|
55
|
+
the screen. Needs a panel with login. ``allow_registration=False`` stops new apps.
|
|
56
|
+
- ``token_minutes``: how long an OAuth access token works before the app refreshes it.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
id = "mcp"
|
|
60
|
+
metadata = McpBase.metadata
|
|
61
|
+
templates = Path(__file__).with_name("templates")
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: str = "/mcp", *, read_only: bool = False, resources: Iterable[Any] | None = None,
|
|
64
|
+
exclude: Iterable[Any] = (), hidden_fields: Iterable[str] = (), max_limit: int = 100,
|
|
65
|
+
name: str | None = None, instructions: str | None = None, public_url: str | None = None,
|
|
66
|
+
oauth: bool = True, allow_registration: bool = True, token_minutes: int = 60) -> None:
|
|
67
|
+
self.path = "/" + path.strip("/")
|
|
68
|
+
self.read_only = read_only
|
|
69
|
+
self.only = {_slug(r) for r in resources} if resources is not None else None
|
|
70
|
+
self.exclude = {_slug(r) for r in exclude} | {McpTokenResource.get_slug()}
|
|
71
|
+
self.hidden_fields = set(hidden_fields)
|
|
72
|
+
self.max_limit = max_limit
|
|
73
|
+
self.name = name
|
|
74
|
+
self.instructions = instructions
|
|
75
|
+
self.public_url = public_url.rstrip("/") if public_url else None
|
|
76
|
+
self.oauth_wanted = oauth
|
|
77
|
+
self.allow_registration = allow_registration
|
|
78
|
+
self.token_seconds = token_minutes * 60
|
|
79
|
+
self.panel: Any = None
|
|
80
|
+
self.server = McpServer(self)
|
|
81
|
+
self.oauth = OAuth(self)
|
|
82
|
+
|
|
83
|
+
def register(self, panel: Any) -> None:
|
|
84
|
+
self.panel = panel
|
|
85
|
+
panel.resources([McpTokenResource])
|
|
86
|
+
panel.routes(self._routes)
|
|
87
|
+
if self.oauth_enabled:
|
|
88
|
+
panel.pages([AuthorizePage])
|
|
89
|
+
panel.routes(self.oauth.routes)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def oauth_enabled(self) -> bool:
|
|
93
|
+
return bool(self.oauth_wanted and self.panel is not None and self.panel.auth.enabled)
|
|
94
|
+
|
|
95
|
+
def mount(self, app: Any, panel: Any) -> None:
|
|
96
|
+
# MCP apps look for the OAuth metadata at the site root, e.g. /.well-known/oauth-authorization-server/admin
|
|
97
|
+
if self.oauth_enabled and panel.path:
|
|
98
|
+
self.oauth.well_known_routes(app, prefix=panel.path)
|
|
99
|
+
self.oauth.well_known_routes(app) # older apps drop the path
|
|
100
|
+
|
|
101
|
+
def _routes(self, app: Any, panel: Any) -> None:
|
|
102
|
+
server = self.server
|
|
103
|
+
|
|
104
|
+
@app.post(self.path)
|
|
105
|
+
async def mcp(request: Request):
|
|
106
|
+
return await server.handle_http(request)
|
|
107
|
+
|
|
108
|
+
@app.get(self.path)
|
|
109
|
+
async def mcp_get(request: Request):
|
|
110
|
+
# no server-sent events stream: clients fall back to plain POSTs
|
|
111
|
+
return JSONResponse({"error": "Use POST for MCP messages."}, status_code=405, headers={"Allow": "POST"})
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------ settings
|
|
114
|
+
def exposes(self, resource: Any) -> bool:
|
|
115
|
+
slug = resource.get_slug()
|
|
116
|
+
if slug in self.exclude:
|
|
117
|
+
return False
|
|
118
|
+
return self.only is None or slug in self.only
|
|
119
|
+
|
|
120
|
+
def hidden_for(self, resource: Any) -> set[str]:
|
|
121
|
+
slug = resource.get_slug()
|
|
122
|
+
out = set()
|
|
123
|
+
for item in self.hidden_fields:
|
|
124
|
+
owner, _, column = item.rpartition(".")
|
|
125
|
+
if not owner or owner == slug:
|
|
126
|
+
out.add(column)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def base_url(self, request: Request) -> str:
|
|
130
|
+
"""``https://host`` of this site (no panel path)."""
|
|
131
|
+
base = self.public_url or self.panel.app_url
|
|
132
|
+
if not base:
|
|
133
|
+
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
134
|
+
base = f"{proto}://{request.headers.get('host') or request.url.netloc}"
|
|
135
|
+
return base.rstrip("/")
|
|
136
|
+
|
|
137
|
+
def endpoint_url(self, request: Request) -> str:
|
|
138
|
+
return self.base_url(request) + self.panel.url(self.path.strip("/"))
|
|
139
|
+
|
|
140
|
+
def server_title(self) -> str:
|
|
141
|
+
return self.name or f"{self.panel.brand_name} admin"
|
|
142
|
+
|
|
143
|
+
def instructions_for(self, can_write: bool) -> str:
|
|
144
|
+
if self.instructions:
|
|
145
|
+
return self.instructions
|
|
146
|
+
text = DEFAULT_INSTRUCTIONS
|
|
147
|
+
if can_write:
|
|
148
|
+
text += (" create_record, update_record and delete_record change data: confirm with the user "
|
|
149
|
+
"before changing or deleting records they did not ask about.")
|
|
150
|
+
else:
|
|
151
|
+
text += " This connection can only read."
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _slug(resource: Any) -> str:
|
|
156
|
+
return resource if isinstance(resource, str) else resource.get_slug()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = [
|
|
160
|
+
"SENSITIVE",
|
|
161
|
+
"AuthorizePage",
|
|
162
|
+
"McpOAuthClient",
|
|
163
|
+
"McpOAuthCode",
|
|
164
|
+
"McpPlugin",
|
|
165
|
+
"McpServer",
|
|
166
|
+
"McpToken",
|
|
167
|
+
"McpTokenResource",
|
|
168
|
+
"OAuth",
|
|
169
|
+
"ToolError",
|
|
170
|
+
"Tools",
|
|
171
|
+
"__version__",
|
|
172
|
+
"hash_token",
|
|
173
|
+
"new_token",
|
|
174
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tables of the MCP plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
|
10
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
11
|
+
|
|
12
|
+
#: every token starts with this, so a leaked one is easy to spot in logs and code
|
|
13
|
+
TOKEN_PREFIX = "tgmcp_"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now() -> dt.datetime:
|
|
17
|
+
return dt.datetime.now()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def new_token() -> str:
|
|
21
|
+
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_token(token: str) -> str:
|
|
25
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class McpBase(DeclarativeBase):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class McpToken(McpBase):
|
|
33
|
+
"""An access token for one AI client. Only its hash is kept; the token itself is shown once."""
|
|
34
|
+
|
|
35
|
+
__tablename__ = "tungsten_mcp_tokens"
|
|
36
|
+
|
|
37
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
38
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
39
|
+
#: the panel user the AI acts as (their roles and permissions apply); None when the panel has no login
|
|
40
|
+
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
41
|
+
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
42
|
+
#: first characters of the token, to tell tokens apart in the list
|
|
43
|
+
hint: Mapped[str] = mapped_column(String(16))
|
|
44
|
+
can_write: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
45
|
+
#: set for tokens an app got by OAuth (its ``client_id``); None for tokens made on the screen
|
|
46
|
+
client_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
47
|
+
#: OAuth access tokens expire; the app swaps its refresh token for a new pair
|
|
48
|
+
expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
|
49
|
+
refresh_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True)
|
|
50
|
+
last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
|
51
|
+
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class McpOAuthClient(McpBase):
|
|
55
|
+
"""An AI app that registered itself (OAuth dynamic client registration)."""
|
|
56
|
+
|
|
57
|
+
__tablename__ = "tungsten_mcp_oauth_clients"
|
|
58
|
+
|
|
59
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
60
|
+
client_id: Mapped[str] = mapped_column(String(64), unique=True)
|
|
61
|
+
#: None for public clients (apps on a computer), which prove themselves with PKCE alone
|
|
62
|
+
secret_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
63
|
+
name: Mapped[str] = mapped_column(String(200))
|
|
64
|
+
redirect_uris: Mapped[list] = mapped_column(JSON)
|
|
65
|
+
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class McpOAuthCode(McpBase):
|
|
69
|
+
"""A one-time code, given to the app after the user pressed Allow."""
|
|
70
|
+
|
|
71
|
+
__tablename__ = "tungsten_mcp_oauth_codes"
|
|
72
|
+
|
|
73
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
74
|
+
code_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
75
|
+
client_id: Mapped[str] = mapped_column(String(64))
|
|
76
|
+
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
77
|
+
redirect_uri: Mapped[str] = mapped_column(Text)
|
|
78
|
+
code_challenge: Mapped[str] = mapped_column(String(128))
|
|
79
|
+
can_write: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
80
|
+
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
|