Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 540e57ec81 | |||
| d1e1043896 | |||
| 6521078b6a | |||
| 2f0a24aceb | |||
| 77bf95817d | |||
| 29a72ab005 | |||
| 33bb464102 | |||
| d4e793224b |
10
.github/workflows/build.yaml
vendored
10
.github/workflows/build.yaml
vendored
@@ -18,10 +18,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata for Docker
|
- name: Extract metadata for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -38,10 +38,10 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ !contains(github.ref, '-alpha') && !contains(github.ref, '-beta') && !contains(github.ref, '-rc') }}
|
type=raw,value=latest,enable=${{ !contains(github.ref, '-alpha') && !contains(github.ref, '-beta') && !contains(github.ref, '-rc') }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-01-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Column Label Support
|
||||||
|
- **`add_column`**: New optional `label` parameter for setting display name
|
||||||
|
- **`modify_column`**: New optional `label` parameter for updating display name
|
||||||
|
|
||||||
|
Labels are human-readable names shown in Grist column headers, separate from the `column_id` used in formulas and API calls. If not provided, Grist defaults the label to the column ID.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
```python
|
||||||
|
# Create column with display label
|
||||||
|
add_column(document="crm", table="Contacts", column_id="first_name", column_type="Text", label="First Name")
|
||||||
|
|
||||||
|
# Update existing column's label
|
||||||
|
modify_column(document="crm", table="Contacts", column_id="first_name", label="Given Name")
|
||||||
|
```
|
||||||
|
|
||||||
## [1.4.1] - 2026-01-14
|
## [1.4.1] - 2026-01-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM python:3.14-slim AS builder
|
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca AS builder
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:c4f5de312ee66d46810635ffc5df34a1973ba753e7241ce3a08ef979ddd7bea5 /uv /usr/local/bin/uv
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ RUN uv sync --frozen --no-dev
|
|||||||
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN useradd --create-home --shell /bin/bash appuser
|
RUN useradd --create-home --shell /bin/bash appuser
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Production environment
|
# Production environment
|
||||||
services:
|
services:
|
||||||
grist-mcp:
|
grist-mcp:
|
||||||
image: ghcr.io/xe138/grist-mcp-server:latest
|
image: ghcr.io/xe138/grist-mcp-server:latest@sha256:2ef22bfac6cfbcbbfc513f61eaea3414b3a531d79e9d1d39bf6757cc9e27ea9a
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -203,11 +203,14 @@ class GristClient:
|
|||||||
column_id: str,
|
column_id: str,
|
||||||
column_type: str,
|
column_type: str,
|
||||||
formula: str | None = None,
|
formula: str | None = None,
|
||||||
|
label: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add a column to a table. Returns column ID."""
|
"""Add a column to a table. Returns column ID."""
|
||||||
fields = {"type": column_type}
|
fields = {"type": column_type}
|
||||||
if formula:
|
if formula:
|
||||||
fields["formula"] = formula
|
fields["formula"] = formula
|
||||||
|
if label:
|
||||||
|
fields["label"] = label
|
||||||
|
|
||||||
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
||||||
data = await self._request("POST", f"/tables/{table}/columns", json=payload)
|
data = await self._request("POST", f"/tables/{table}/columns", json=payload)
|
||||||
@@ -219,13 +222,16 @@ class GristClient:
|
|||||||
column_id: str,
|
column_id: str,
|
||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
formula: str | None = None,
|
formula: str | None = None,
|
||||||
|
label: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Modify a column's type or formula."""
|
"""Modify a column's type, formula, or label."""
|
||||||
fields = {}
|
fields = {}
|
||||||
if type is not None:
|
if type is not None:
|
||||||
fields["type"] = type
|
fields["type"] = type
|
||||||
if formula is not None:
|
if formula is not None:
|
||||||
fields["formula"] = formula
|
fields["formula"] = formula
|
||||||
|
if label is not None:
|
||||||
|
fields["label"] = label
|
||||||
|
|
||||||
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
||||||
await self._request("PATCH", f"/tables/{table}/columns", json=payload)
|
await self._request("PATCH", f"/tables/{table}/columns", json=payload)
|
||||||
|
|||||||
@@ -186,13 +186,14 @@ def create_server(
|
|||||||
"column_id": {"type": "string"},
|
"column_id": {"type": "string"},
|
||||||
"column_type": {"type": "string"},
|
"column_type": {"type": "string"},
|
||||||
"formula": {"type": "string"},
|
"formula": {"type": "string"},
|
||||||
|
"label": {"type": "string", "description": "Display label for the column"},
|
||||||
},
|
},
|
||||||
"required": ["document", "table", "column_id", "column_type"],
|
"required": ["document", "table", "column_id", "column_type"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Tool(
|
Tool(
|
||||||
name="modify_column",
|
name="modify_column",
|
||||||
description="Modify a column's type or formula",
|
description="Modify a column's type, formula, or label",
|
||||||
inputSchema={
|
inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -201,6 +202,7 @@ def create_server(
|
|||||||
"column_id": {"type": "string"},
|
"column_id": {"type": "string"},
|
||||||
"type": {"type": "string"},
|
"type": {"type": "string"},
|
||||||
"formula": {"type": "string"},
|
"formula": {"type": "string"},
|
||||||
|
"label": {"type": "string", "description": "Display label for the column"},
|
||||||
},
|
},
|
||||||
"required": ["document", "table", "column_id"],
|
"required": ["document", "table", "column_id"],
|
||||||
},
|
},
|
||||||
@@ -311,6 +313,7 @@ def create_server(
|
|||||||
_current_agent, auth, arguments["document"], arguments["table"],
|
_current_agent, auth, arguments["document"], arguments["table"],
|
||||||
arguments["column_id"], arguments["column_type"],
|
arguments["column_id"], arguments["column_type"],
|
||||||
formula=arguments.get("formula"),
|
formula=arguments.get("formula"),
|
||||||
|
label=arguments.get("label"),
|
||||||
)
|
)
|
||||||
elif name == "modify_column":
|
elif name == "modify_column":
|
||||||
result = await _modify_column(
|
result = await _modify_column(
|
||||||
@@ -318,6 +321,7 @@ def create_server(
|
|||||||
arguments["column_id"],
|
arguments["column_id"],
|
||||||
type=arguments.get("type"),
|
type=arguments.get("type"),
|
||||||
formula=arguments.get("formula"),
|
formula=arguments.get("formula"),
|
||||||
|
label=arguments.get("label"),
|
||||||
)
|
)
|
||||||
elif name == "delete_column":
|
elif name == "delete_column":
|
||||||
result = await _delete_column(
|
result = await _delete_column(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async def add_column(
|
|||||||
column_id: str,
|
column_id: str,
|
||||||
column_type: str,
|
column_type: str,
|
||||||
formula: str | None = None,
|
formula: str | None = None,
|
||||||
|
label: str | None = None,
|
||||||
client: GristClient | None = None,
|
client: GristClient | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add a column to a table."""
|
"""Add a column to a table."""
|
||||||
@@ -40,7 +41,9 @@ async def add_column(
|
|||||||
doc = auth.get_document(document)
|
doc = auth.get_document(document)
|
||||||
client = GristClient(doc)
|
client = GristClient(doc)
|
||||||
|
|
||||||
created_id = await client.add_column(table, column_id, column_type, formula=formula)
|
created_id = await client.add_column(
|
||||||
|
table, column_id, column_type, formula=formula, label=label
|
||||||
|
)
|
||||||
return {"column_id": created_id}
|
return {"column_id": created_id}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,16 +55,17 @@ async def modify_column(
|
|||||||
column_id: str,
|
column_id: str,
|
||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
formula: str | None = None,
|
formula: str | None = None,
|
||||||
|
label: str | None = None,
|
||||||
client: GristClient | None = None,
|
client: GristClient | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Modify a column's type or formula."""
|
"""Modify a column's type, formula, or label."""
|
||||||
auth.authorize(agent, document, Permission.SCHEMA)
|
auth.authorize(agent, document, Permission.SCHEMA)
|
||||||
|
|
||||||
if client is None:
|
if client is None:
|
||||||
doc = auth.get_document(document)
|
doc = auth.get_document(document)
|
||||||
client = GristClient(doc)
|
client = GristClient(doc)
|
||||||
|
|
||||||
await client.modify_column(table, column_id, type=type, formula=formula)
|
await client.modify_column(table, column_id, type=type, formula=formula, label=label)
|
||||||
return {"modified": True}
|
return {"modified": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,27 @@ async def test_add_column(client, httpx_mock: HTTPXMock):
|
|||||||
col_id = await client.add_column("Table1", "NewCol", "Text", formula=None)
|
col_id = await client.add_column("Table1", "NewCol", "Text", formula=None)
|
||||||
|
|
||||||
assert col_id == "NewCol"
|
assert col_id == "NewCol"
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
import json
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload == {"columns": [{"id": "NewCol", "fields": {"type": "Text"}}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_column_with_label(client, httpx_mock: HTTPXMock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
||||||
|
method="POST",
|
||||||
|
json={"columns": [{"id": "first_name"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
col_id = await client.add_column("Table1", "first_name", "Text", label="First Name")
|
||||||
|
|
||||||
|
assert col_id == "first_name"
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
import json
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload == {"columns": [{"id": "first_name", "fields": {"type": "Text", "label": "First Name"}}]}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -169,6 +190,22 @@ async def test_modify_column(client, httpx_mock: HTTPXMock):
|
|||||||
await client.modify_column("Table1", "Amount", type="Int", formula="$Price * $Qty")
|
await client.modify_column("Table1", "Amount", type="Int", formula="$Price * $Qty")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_column_with_label(client, httpx_mock: HTTPXMock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
||||||
|
method="PATCH",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.modify_column("Table1", "Col1", label="Column One")
|
||||||
|
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
import json
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload == {"columns": [{"id": "Col1", "fields": {"label": "Column One"}}]}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_column(client, httpx_mock: HTTPXMock):
|
async def test_delete_column(client, httpx_mock: HTTPXMock):
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ async def test_add_column(auth, mock_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"column_id": "NewCol"}
|
assert result == {"column_id": "NewCol"}
|
||||||
|
mock_client.add_column.assert_called_once_with(
|
||||||
|
"Table1", "NewCol", "Text", formula=None, label=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_column_with_label(auth, mock_client):
|
||||||
|
agent = auth.authenticate("schema-token")
|
||||||
|
|
||||||
|
result = await add_column(
|
||||||
|
agent, auth, "budget", "Table1", "first_name", "Text",
|
||||||
|
label="First Name",
|
||||||
|
client=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"column_id": "NewCol"}
|
||||||
|
mock_client.add_column.assert_called_once_with(
|
||||||
|
"Table1", "first_name", "Text", formula=None, label="First Name"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -95,6 +114,25 @@ async def test_modify_column(auth, mock_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"modified": True}
|
assert result == {"modified": True}
|
||||||
|
mock_client.modify_column.assert_called_once_with(
|
||||||
|
"Table1", "Col1", type="Int", formula="$A + $B", label=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_column_with_label(auth, mock_client):
|
||||||
|
agent = auth.authenticate("schema-token")
|
||||||
|
|
||||||
|
result = await modify_column(
|
||||||
|
agent, auth, "budget", "Table1", "Col1",
|
||||||
|
label="Column One",
|
||||||
|
client=mock_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"modified": True}
|
||||||
|
mock_client.modify_column.assert_called_once_with(
|
||||||
|
"Table1", "Col1", type=None, formula=None, label="Column One"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user