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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ frontend/node_modules/
9
+ storage/
10
+ *.db
11
+ *.sqlite3
12
+ .DS_Store
13
+ .claude/worktrees/
@@ -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)