Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use flake
24 changes: 24 additions & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Deploy to PyPI
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy-pypi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Load Age private key
run: |
mkdir -p ~/.config/sops/age
echo "${{ secrets.SOPS_AGE_KEY }}" > ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
- name: Deploy
run: |
nix run .#deploy
- name: Cleanup secrets
if: always()
run: "rm -f ~/.config/sops/age/keys.txt || true \n"
16 changes: 16 additions & 0 deletions .github/workflows/development.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: CI
on:
push:
branches-ignore:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Run api lint
run: |
nix run .#lint
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ build/
dist/
wheels/
*.egg-info

.mypy_cache/
# Virtual environments
.venv
.direnv
.ruff_cache
50 changes: 33 additions & 17 deletions mcp_graphql/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,8 @@ def _process_nested_type(
current_depth: int,
) -> ProcessedNestedType:
"""Process a nested type field."""
# Handle non-null and list wrappers
while hasattr(nested_type, "of_type"):
nested_type = nested_type.of_type
# Unwrap wrappers to get the concrete type
nested_type = _unwrap_wrapped_type(nested_type)

# Only process if we actually have a GraphQLObjectType
if isinstance(nested_type, GraphQLObjectType):
Expand Down Expand Up @@ -235,12 +234,8 @@ def build_nested_selection(
if _should_skip_field(field_name, field_value):
continue

# Unwrap NonNull for easier handling
value_type = (
field_value.type.of_type
if isinstance(field_value.type, GraphQLNonNull)
else field_value.type
)
# Determine the underlying GraphQL type (skip NonNull / List wrappers)
value_type = _unwrap_wrapped_type(field_value.type)

# Scalars can be added directly
if isinstance(value_type, GraphQLScalarType):
Expand Down Expand Up @@ -271,10 +266,7 @@ def build_selection(
field = getattr(parent, field_name)

# Get the field type and handle wrapped types (List, NonNull)
field_type = field.field.type
# Unwrap NonNull and List types to get the inner type
while hasattr(field_type, "of_type"):
field_type = field_type.of_type
field_type = _unwrap_wrapped_type(field.field.type)

# Check if this is a scalar type or an object type
is_scalar = isinstance(field_type, GraphQLScalarType)
Expand All @@ -284,7 +276,11 @@ def build_selection(
result.append(getattr(parent, field_name))
elif nested_selections and len(nested_selections) > 0:
# This is a non-scalar with valid nested selections
nested_fields = build_selection(ds, getattr(ds, field_type.name), nested_selections)
nested_fields = build_selection(
ds,
getattr(ds, cast("Any", field_type).name),
nested_selections,
)
if nested_fields:
result.append(field.select(*nested_fields))
# Skip fields that have no valid nested selections and aren't scalars
Expand Down Expand Up @@ -378,10 +374,14 @@ async def call_tool_impl(

# Unwrap the type (NonNull, List) to get to the actual type name
field_type = attr.field.type
# Keep unwrapping until we find a type with a name attribute
while hasattr(field_type, "of_type") and not hasattr(field_type, "name"):
# Unwrap until we hit a type that exposes a ``name`` attribute
while not hasattr(field_type, "name") and hasattr(field_type, "of_type"):
# Access dynamically to appease static type checkers
field_type = field_type.of_type

# Ensure we end up with the innermost, unwrapped type
field_type = _unwrap_wrapped_type(field_type)

# Now we should have the actual type with a name
if not hasattr(field_type, "name"):
return [
Expand All @@ -391,7 +391,7 @@ async def call_tool_impl(
),
]

return_type: DSLType = getattr(ds, field_type.name)
return_type: DSLType = getattr(ds, cast("Any", field_type).name)

# Build the query with nested selections
selections = build_nested_selection(return_type._type, max_depth)
Expand Down Expand Up @@ -460,3 +460,19 @@ async def read_resource_impl(uri: AnyUrl) -> Resource:
),
),
)


def _unwrap_wrapped_type(gql_type: Any) -> Any: # noqa: ANN401
"""Return the innermost GraphQL type.

GraphQL exposes wrapper types (``GraphQLNonNull``/``GraphQLList``) that add
an ``of_type`` attribute pointing at the underlying type. For code that
needs the concrete type (e.g. to check whether it is a scalar/object) we
repeatedly follow that attribute until we reach a type that is not itself a
wrapper. The parameter is typed as *Any* so static analysers do not shout
about the dynamic attribute access.
"""

while hasattr(gql_type, "of_type"):
gql_type = gql_type.of_type
return gql_type
1 change: 1 addition & 0 deletions nix/pkgs/default.nix
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{ lib', pkgs }: {
lint = import ./lint { inherit lib' pkgs; };
deploy = import ./deploy { inherit lib' pkgs; };
}
26 changes: 26 additions & 0 deletions nix/pkgs/deploy/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{ lib', pkgs }:
pkgs.writeShellApplication {
name = "mcp_graphql-deploy";
runtimeInputs = pkgs.lib.flatten [
pkgs.uv
lib'.envs.default
];
text = ''
pyproject_toml="pyproject.toml"

if ! git diff HEAD~1 HEAD -- "''${pyproject_toml}" | grep -q '^[-+]version'; then
: && info "''${pyproject_toml} version has not changed. Skipping deployment." \
&& return 0
fi

set -o allexport
eval "$(sops -d --output-type dotenv secrets.yaml)"
set +o allexport


echo "Publishing new version for mcp_graphql"
rm -rf "dist"
uv build
uv publish --token "''${PYPI_API_TOKEN}"
'';
}
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[project]
name = "mcp-graphql"
version = "0.3.0"
version = "0.3.1"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"aiohttp>=3.11.16",
"click>=8.1.8",
"gql>=3.5.2",
"mcp[cli]>=1.6.0",
"mcp[cli]>=1.10.1",
]

[project.scripts]
Expand Down
17 changes: 17 additions & 0 deletions secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
PYPI_API_TOKEN: ENC[AES256_GCM,data:VO/lwP+LhPABWChkG7lleIBioTmLTck6zNMXWrKDGf1vR0+6iD/aTj/NO/A9G/8HnrW5l2ddO9K5mYjaI9cLLckICz2FmAkjKRpjrTSSgE7FSQHlkmy88ZThLZzhv03qKCi+hGXHWB4eUiQoBoifTEV2Hg1mZeB9F4lAif/CKWPqoGC2haAnk+Ob5XWB4YTh7c07OJ/JCKpSl3rsC8QvhRkHUz33JBNgh9RUVPbv69XwxQk=,iv:Vrxgx39mbkOGnobAqAXREIj/+ibU7yOGe52jgTiI6/4=,tag:/dtCkIEClAF2NocovNCY7g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxZnA2R2VzM0dvY25WY25t
bERXUU5ZZzZNUHJPSHcrZll6WFNna3ZCQ1Q0Cnd5bW5remhKUDJOcXdmOGtCbk91
anBIL1Uxd2pmclBYdVFSc0RINFJpZE0KLS0tICtPbFZ2dDZGcjVFWUh6bmNyREpM
Wk1uYTV0cjRUVVhzWDNETGh3aHdUR2MKC9CbAhF92mQH8ZBJo+6P46B3QahFzL6I
ROOr/ZReYqFPOok+IJWT7YePigtZGRbI+uPqCfGj3X1Tbs7HG675nw==
-----END AGE ENCRYPTED FILE-----
recipient: age10zqm2nrvsrm8ml22wlzx4lyjlle3je4l4e9v6fyfuczz8arkfqfsmpns80
lastmodified: '2025-07-27T14:31:19Z'
mac: ENC[AES256_GCM,data:3bUpU3MKLyhbQiLqZPifkzXtywxmrIle3IJ78LHzo+yDezx0r80h4qXqAzUuxq5O4UAKKgfTwi5SE2Ha5IdjW4azHeJuvAlg+whEEfY0AjShMWWSUdezDGgQ1I4DW2ZuDINgC1zbDo9npZUNYa4tmx59rb443D/Vfh4fRNVY+8E=,iv:c+Zm3Qh2Q63cEKQ8cjfytyPkU4Wja8+np5PdV0sJoQM=,tag:TGHdHidIdFO+I1WcG7QnmQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.10.2
Loading