Skip to content

Commit 20e0159

Browse files
authored
feat: Auto generate MCP tools through graphql introspection (#6397)
* feat: mcp autogen first pass * fix: add medium sized tests * fix: support nested payload fields * fix: handle queries * fix: collect files * fix: further clean up * chore: update lock * fix: deps * more clean up * fix * fix: additional logging * Delete .claude/settings.local.json
1 parent 9ccff06 commit 20e0159

File tree

20 files changed

+3117
-59
lines changed

20 files changed

+3117
-59
lines changed

apps/frontend/app/api/v1/osograph/schema/graphql/system.graphql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,45 @@ type System {
2222
extend type Mutation {
2323
"""
2424
System only. Mark a run as started.
25+
@system-only
2526
"""
2627
startRun(input: StartRunInput!): StartRunPayload!
2728

2829
"""
2930
System only. Update run metadata. This can be called at any time
31+
@system-only
3032
"""
3133
updateRunMetadata(input: UpdateRunMetadataInput!): UpdateRunMetadataPayload!
3234

3335
"""
3436
System only. Mark a run as finished.
37+
@system-only
3538
"""
3639
finishRun(input: FinishRunInput!): FinishRunPayload!
3740

3841
"""
3942
System only. Mark a step as started
43+
@system-only
4044
"""
4145
startStep(input: StartStepInput!): StartStepPayload!
4246

4347
"""
4448
System only. Mark a step as finished
49+
@system-only
4550
"""
4651
finishStep(input: FinishStepInput!): FinishStepPayload!
4752

4853
"""
4954
System only. Create a materialization for a step
55+
@system-only
5056
"""
5157
createMaterialization(
5258
input: CreateMaterializationInput!
5359
): CreateMaterializationPayload!
5460

5561
"""
5662
System only. Save the generated published notebook HTML to object storage
63+
@system-only
5764
"""
5865
savePublishedNotebookHtml(
5966
input: SavePublishedNotebookHtmlInput!

uv.lock

Lines changed: 360 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

warehouse/oso_agent/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
77
authors = [{ name = "OSO Team", email = "opensource-observer@googlegroups.com" }]
88
dependencies = [
99
"arize-phoenix-otel>=0.10.1",
10-
"arize-phoenix[evals]==11.28.0",
10+
"arize-phoenix[evals]>=12.0.0",
1111
"discord-py>=2.5.2",
1212
"dotenv>=0.9.9",
1313
"llama-index>=0.12.29",
@@ -26,7 +26,7 @@ dependencies = [
2626
"pytest-asyncio>=0.26.0",
2727
"scikit-learn>=1.6.1",
2828
"sqlglot[rs]>=26.16.4",
29-
"uvicorn[standard]==0.34.3",
29+
"uvicorn[standard]>=0.35",
3030
]
3131

3232
[tool.uv.sources]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import logging
2+
import sys
3+
4+
import pytest
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
@pytest.fixture(scope="session", autouse=True)
10+
def setup_debug_logging_for_tests() -> None:
11+
root_logger = logging.getLogger(__name__.split(".")[0])
12+
root_logger.setLevel(logging.DEBUG)
13+
14+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)

warehouse/oso_mcp/oso_mcp/serve.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
This should be considered a _test_ only module that is used to run the dev
3+
server for FastMCP
4+
"""
5+
6+
from dotenv import load_dotenv
7+
from oso_core.logging import setup_module_logging
8+
from oso_mcp.server.app import setup_mcp_app
9+
from oso_mcp.server.config import MCPConfig
10+
11+
load_dotenv()
12+
13+
setup_module_logging("oso_mcp")
14+
15+
config = MCPConfig()
16+
17+
app = setup_mcp_app(config)

warehouse/oso_mcp/oso_mcp/server/app.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import base64
22
import hashlib
3+
import os
34
import uuid
45
from collections.abc import AsyncIterator
56
from contextlib import asynccontextmanager
67
from dataclasses import dataclass
78
from typing import Any, Generic, List, Optional, TypeVar, Union
89

910
import requests
10-
from mcp.server.fastmcp import Context, FastMCP
11+
from fastmcp import Context, FastMCP
12+
from oso_mcp.server.graphql import generate_from_schema
13+
from oso_mcp.server.graphql.mutations import RegexMutationFilter
1114
from pyoso import Client, ClientConfig
1215

1316
from .config import MCPConfig
1417

18+
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
19+
REPO_DIR = os.path.abspath(os.path.join(CURR_DIR, "../../../../"))
20+
1521
MCP_SSE_PORT = 8000
1622

1723
P = TypeVar("P")
@@ -67,10 +73,23 @@ def setup_mcp_app(config: MCPConfig):
6773
"OSO Data Lake Explorer",
6874
port=config.port,
6975
host=config.host,
70-
dependencies=["pyoso", "python-dotenv", "requests"],
76+
dependencies=["pyoso", "python-dotenv", "requests", "httpx"],
7177
lifespan=default_lifespan(config),
7278
)
7379

80+
# Wire autogenerated tools from graphql queries and mutations
81+
generate_from_schema(
82+
schema_path=os.path.join(
83+
REPO_DIR, "apps/frontend/app/api/v1/osograph/schema/graphql"
84+
),
85+
mcp=mcp,
86+
config=config,
87+
filters=[
88+
RegexMutationFilter(patterns=["@mcp-ignore", "@system-only"]),
89+
],
90+
client_schema_path=os.path.join(CURR_DIR, "graphql/queries"),
91+
)
92+
7493
@mcp.tool(
7594
description="Convert a natural language question into a SQL query using the OSO text2sql agent. Returns the generated SQL string.",
7695
)
@@ -118,9 +137,7 @@ async def query_text2sql_agent(nl_query: str, ctx: Context) -> McpResponse:
118137
@mcp.tool(
119138
description="Generates a deterministic OSO ID (SHA256 hash base64 encoded) from a list of input values. Use this to verify IDs in tests.",
120139
)
121-
async def generate_oso_id(
122-
args: List[Any], ctx: Context
123-
) -> McpResponse:
140+
async def generate_oso_id(args: List[Any], ctx: Context) -> McpResponse:
124141
"""
125142
Generates a deterministic OSO ID.
126143

warehouse/oso_mcp/oso_mcp/server/config.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import os
32
import typing as t
43

54
from pydantic import Field, SecretStr
@@ -22,24 +21,29 @@ class MCPConfig(BaseSettings):
2221
model_config = mcp_config_dict()
2322

2423
oso_api_key: SecretStr = Field(
25-
default_factory=lambda: SecretStr(
26-
os.environ.get(
27-
"COPILOT_MCP_OSO_API_KEY", os.environ.get("MCP_OSO_API_KEY", "")
28-
)
29-
),
3024
description="API key for the OSO API",
3125
json_schema_extra={
3226
"required": True
3327
}, # This is the key to make the field required
3428
)
3529

36-
pyoso_base_url: str = Field(
37-
default="https://www.opensource.observer",
30+
oso_base_url: str = Field(
31+
default="https://www.oso.xyz",
32+
description="Base URL for the OSO API",
33+
)
34+
35+
pyoso_base_path: str = Field(
36+
default="/api/v1/",
3837
description="Base URL for the OSO pyoso client",
3938
)
4039

41-
text2sql_endpoint: str = Field(
42-
default="https://www.opensource.observer/api/v1/text2sql",
40+
graphql_path: str = Field(
41+
default="/api/v1/osograph",
42+
description="Path for the OSO GraphQL endpoint",
43+
)
44+
45+
text2sql_path: str = Field(
46+
default="/api/v1/text2sql",
4347
description="URL endpoint for the OSO text2sql service",
4448
)
4549

@@ -136,3 +140,18 @@ def print_env_schema(cls):
136140
print(f"{env_var}: OPTIONAL (DEFAULT={default_display})")
137141

138142
print("=" * 50)
143+
144+
@property
145+
def graphql_endpoint(self) -> str:
146+
"""Get the full GraphQL endpoint URL."""
147+
return f"{self.oso_base_url}{self.graphql_path}"
148+
149+
@property
150+
def text2sql_endpoint(self) -> str:
151+
"""Get the full Text2SQL endpoint URL."""
152+
return f"{self.oso_base_url}{self.text2sql_path}"
153+
154+
@property
155+
def pyoso_base_url(self) -> str:
156+
"""Get the full base URL for the pyoso client."""
157+
return f"{self.oso_base_url}{self.pyoso_base_path}"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""GraphQL tool generation for FastMCP.
2+
3+
This package provides utilities to automatically generate FastMCP tools from GraphQL schemas.
4+
"""
5+
6+
from .generator import generate_from_schema
7+
8+
__all__ = ["generate_from_schema"]
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Main GraphQL tool generator orchestrator."""
2+
3+
import json
4+
import logging
5+
import typing as t
6+
from contextlib import asynccontextmanager
7+
8+
import httpx
9+
from ariadne_codegen.schema import get_graphql_schema_from_path
10+
from fastmcp import FastMCP
11+
from oso_mcp.server.config import MCPConfig
12+
13+
from .mutations import MutationExtractor
14+
from .pydantic_generator import PydanticModelGenerator
15+
from .queries import QueryDocumentParser, QueryExtractor
16+
from .tool_generator import ToolGenerator
17+
from .types import AsyncGraphQLClient, GraphQLClientFactory, MutationFilter
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class OSOAsyncGraphQLClient(AsyncGraphQLClient):
23+
"""Asynchronous GraphQL client using httpx."""
24+
25+
def __init__(
26+
self,
27+
endpoint: str,
28+
http_client: httpx.AsyncClient,
29+
api_key: str,
30+
):
31+
self.endpoint = endpoint
32+
self.http_client = http_client
33+
self.api_key = api_key
34+
35+
async def execute(
36+
self,
37+
query: str,
38+
operation_name: str,
39+
variables: dict[str, t.Any] | None = None,
40+
headers: dict[str, str] | None = None,
41+
) -> t.Any:
42+
"""Execute a GraphQL query asynchronously.
43+
44+
Args:
45+
query: GraphQL query string
46+
variables: Optional variables for the query
47+
48+
Returns:
49+
Parsed JSON response from the GraphQL server
50+
"""
51+
payload: dict[str, t.Any] = {
52+
"query": query,
53+
"operationName": operation_name,
54+
}
55+
if variables:
56+
payload["variables"] = variables
57+
else:
58+
payload["variables"] = {}
59+
60+
headers = {
61+
"Authorization": f"Bearer {self.api_key}",
62+
"Content-Type": "application/json",
63+
"Agent": "oso-mcp-client/0.0",
64+
}
65+
logger.debug(f"Executing GraphQL request: \n\n {json.dumps(payload, indent=2)}")
66+
67+
# Make HTTP request
68+
response = await self.http_client.post(
69+
self.endpoint, json=payload, headers=headers
70+
)
71+
response.raise_for_status()
72+
return response.json()
73+
74+
75+
def default_http_client_factory(config: MCPConfig) -> GraphQLClientFactory:
76+
"""Create a default HTTP client for GraphQL requests."""
77+
78+
@asynccontextmanager
79+
async def _http_client_factory() -> t.AsyncGenerator[AsyncGraphQLClient, None]:
80+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=30.0)) as client:
81+
yield OSOAsyncGraphQLClient(
82+
endpoint=config.graphql_endpoint,
83+
http_client=client,
84+
api_key=config.oso_api_key.get_secret_value(),
85+
)
86+
87+
return _http_client_factory
88+
89+
90+
def generate_from_schema(
91+
schema_path: str,
92+
mcp: FastMCP,
93+
filters: list[MutationFilter],
94+
config: MCPConfig,
95+
client_schema_path: str | None = None,
96+
graphql_client_factory: GraphQLClientFactory | None = None,
97+
) -> None:
98+
"""Generate and register FastMCP tools from GraphQL schema.
99+
100+
Args:
101+
schema_path: Path to GraphQL schema directory or file
102+
mcp: FastMCP instance to register tools on
103+
config: Tool configuration
104+
client_schema_path: Optional path to directory containing client
105+
GraphQL query files
106+
"""
107+
# Load GraphQL schema
108+
schema = get_graphql_schema_from_path(schema_path)
109+
110+
# Create Pydantic model generator
111+
model_generator = PydanticModelGenerator()
112+
113+
# Extract mutations from schema
114+
mutation_extractor = MutationExtractor(schema)
115+
mutations = mutation_extractor.extract_mutations(model_generator, filters)
116+
117+
# Extract queries from client files if provided
118+
queries = []
119+
if client_schema_path:
120+
# Parse client query files
121+
parser = QueryDocumentParser(client_schema_path)
122+
query_docs = parser.parse_all()
123+
124+
# Extract queries
125+
query_extractor = QueryExtractor()
126+
queries = query_extractor.extract_queries(schema, query_docs, model_generator)
127+
128+
if not graphql_client_factory:
129+
graphql_client_factory = default_http_client_factory(config)
130+
131+
# Generate and register tools
132+
tool_gen = ToolGenerator(
133+
mcp,
134+
mutations,
135+
graphql_endpoint=config.graphql_endpoint,
136+
graphql_client_factory=graphql_client_factory,
137+
queries=queries,
138+
)
139+
tool_gen.generate_mutation_tools()
140+
if queries:
141+
tool_gen.generate_query_tools()

0 commit comments

Comments
 (0)