Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6a03cbd
AI-1343 feat: add data app component id
mariankrotil Aug 12, 2025
a7b25c7
AI-1343 feat: create client for data science service, create client s…
mariankrotil Aug 14, 2025
1c03526
AI-1343 refactor: move client file to client subpackage and create ba…
mariankrotil Aug 14, 2025
d18d013
AI-1343 refactor: update imports wrt client submodule changes
mariankrotil Aug 14, 2025
971cd97
AI-1343 feat: add encryption client to encrpyt values
mariankrotil Aug 14, 2025
cea8f3f
AI-1343 style: update code wrt flake8 using black and isort
mariankrotil Aug 14, 2025
a3e98de
AI-1343 fix: update endpoints, docs, refactor
mariankrotil Aug 14, 2025
6dfc84d
AI-1343 feat: update data science client, add password, deploy, and s…
mariankrotil Aug 15, 2025
4146e06
AI-1343 feat: add patch method to the base client
mariankrotil Aug 15, 2025
f9a9e22
AI-1343 feat: add method getting versions of component from versions …
mariankrotil Aug 15, 2025
4ecb6d1
AI-1343 style: apply flake8 changes
mariankrotil Aug 15, 2025
4bb1f9f
Merge branch 'main' into AI-1343-mcp-add-support-for-data-apps
mariankrotil Aug 15, 2025
a95af68
Merge branch 'main' into AI-1343-mcp-add-support-for-data-apps
mariankrotil Aug 18, 2025
92042e3
AI-1343 feat(client): add handlers for listing, getting logs and dele…
mariankrotil Aug 18, 2025
2eab15a
AI-1343 feat: add get method returning text value instead of json
mariankrotil Aug 18, 2025
9b08f89
AI-1343 test: Move tests for client to the corresponding dir, add tes…
mariankrotil Aug 18, 2025
ad226b5
AI-1343 refactor: move client tests to the cor. dir
mariankrotil Aug 18, 2025
4c9a436
AI-1343 style: apply black, isort and flake8 changes
mariankrotil Aug 18, 2025
9a31c75
AI-1343 test: add client ecryption integ tests
mariankrotil Aug 18, 2025
86489a9
AI-1343 refactor: move clients to separate submodules
mariankrotil Aug 18, 2025
892b024
AI-1343 test: skip global serarch test for client method as it is uns…
mariankrotil Aug 18, 2025
51f2bfb
AI-1343 style: apply isort
mariankrotil Aug 18, 2025
ad951c7
AI-1343 refactor: validate parameters in encryption client for encryp…
mariankrotil Aug 19, 2025
7ddeea2
AI-1343 fix: make clients token parameter optional and handle headers…
mariankrotil Aug 19, 2025
0fb290f
AI-1343 style: apply black
mariankrotil Aug 19, 2025
a7b124e
AI-1343 refactor: update description, rename classes wrt reviews
mariankrotil Aug 19, 2025
5efd70b
Merge branch 'main' into AI-1343-mcp-add-support-for-data-apps
mariankrotil Aug 19, 2025
7cda4d8
AI-1343 style: apply tox
mariankrotil Aug 19, 2025
008f229
AI-1343 docs: improve client method get_text description to follow re…
mariankrotil Aug 19, 2025
5e89017
AI-1343 refactor: update parameters for client methods - structured, …
mariankrotil Aug 19, 2025
8247f12
AI-1343 test: add unit tests for data science client tail_app_log method
mariankrotil Aug 19, 2025
b503441
AI-1343 test: improve tests with asynccontext for initial data app tests
mariankrotil Aug 19, 2025
b48ff37
AI-1343 fix: increase waiting time for global search
mariankrotil Aug 19, 2025
4457d0c
AI-1343 refactor: update parameter types for encrypt method wrt reviews
mariankrotil Aug 19, 2025
812a341
AI-1343 test: unskip the global search test
mariankrotil Aug 19, 2025
a9359bf
AI-1343 refactor: remove async annot for non-async test, improve test…
mariankrotil Aug 19, 2025
a16b0de
AI-1343 refactor: add shortcut imports
mariankrotil Aug 19, 2025
558a387
AI-1343 refactor: change Optional[s] to s | None in clients, ref code
mariankrotil Aug 20, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import pytest

from integtests.conftest import ProjectDef, TableDef
from keboola_mcp_server.client import AsyncStorageClient, GlobalSearchResponse, KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.clients.storage import AsyncStorageClient, GlobalSearchResponse

LOG = logging.getLogger(__name__)

Expand All @@ -25,6 +26,7 @@ async def test_global_search(self, storage_client: AsyncStorageClient):
assert ret.by_project == {}

@pytest.mark.asyncio
@pytest.mark.skip(reason='Writing to the global search index takes too long, making the test unstable.')
async def test_global_search_with_results(self, storage_client: AsyncStorageClient, tables: list[TableDef]):
search_for_name = 'test'
is_global_search_enabled = await storage_client.is_enabled('global-search')
Expand Down
105 changes: 105 additions & 0 deletions integtests/clients/test_data_science.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import logging

import pytest

from keboola_mcp_server.clients.client import DATA_APP_COMPONENT_ID, KeboolaClient
from keboola_mcp_server.clients.data_science import DataAppResponse, DataScienceClient

LOG = logging.getLogger(__name__)


def _minimal_parameters(slug: str) -> dict[str, object]:
"""Build minimal valid parameters for a code-based Streamlit data app."""
return {
'size': 'tiny',
'autoSuspendAfterSeconds': 600,
'dataApp': {
'slug': slug,
'streamlit': {
'config.toml': '[theme]\nbase = "light"',
},
},
'script': [
'import streamlit as st',
"st.write('Hello from integration test')",
],
}


def _public_access_authorization() -> dict[str, object]:
"""Allow public access to all paths; no providers required."""
return {
'app_proxy': {
'auth_providers': [],
'auth_rules': [
{'type': 'pathPrefix', 'value': '/', 'auth_required': False},
],
}
}


@pytest.fixture
def ds_client(keboola_client: KeboolaClient) -> DataScienceClient:
return keboola_client.data_science_client


@pytest.mark.asyncio
async def test_create_and_fetch_data_app(
ds_client: DataScienceClient, unique_id: str, keboola_client: KeboolaClient
) -> None:
"""Test creating a data app and fetching it from detail and list endpoints"""
slug = f'test-app-{unique_id}'
created: DataAppResponse = await ds_client.create_data_app(
name=f'IntegTest {slug}',
description='Created by integration tests',
parameters=_minimal_parameters(slug),
authorization=_public_access_authorization(),
)

try:
# Check if the created data app is valid
assert isinstance(created, DataAppResponse)
assert created.id
assert created.type == 'streamlit'
assert created.component_id == DATA_APP_COMPONENT_ID

# Deploy the data app
response = await ds_client.deploy_data_app(created.id, created.config_version)
assert response.id == created.id

# Fetch the data app from data science
fethced_ds = await ds_client.get_data_app(created.id)
assert fethced_ds.id == created.id
assert fethced_ds.type == created.type
assert fethced_ds.component_id == created.component_id
assert fethced_ds.project_id == created.project_id
assert fethced_ds.config_id == created.config_id
assert fethced_ds.config_version == created.config_version

# Fetch the data app config from storage
fetched_s = await keboola_client.storage_client.configuration_detail(
component_id=DATA_APP_COMPONENT_ID,
configuration_id=created.config_id,
)

# check if the data app ids are the same (data app from data science and config from storage)
assert 'configuration' in fetched_s
assert isinstance(fetched_s['configuration'], dict)
assert 'parameters' in fetched_s['configuration']
assert isinstance(fetched_s['configuration']['parameters'], dict)
assert 'id' in fetched_s['configuration']['parameters']
assert fethced_ds.id == fetched_s['configuration']['parameters']['id']

# Fetch the all data apps and check if the created data app is in the list
data_apps = await ds_client.list_data_apps()
assert isinstance(data_apps, list)
assert len(data_apps) > 0
assert any(app.id == created.id for app in data_apps)

finally:
for _ in range(2): # Delete configuration 2 times (from storage and then from temporal bin)
try:
await ds_client.delete_data_app(created.id)
except Exception as e:
LOG.exception(f'Failed to delete data app: {e}')
pass
44 changes: 44 additions & 0 deletions integtests/clients/test_encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import Any

import pytest

from keboola_mcp_server.clients.client import DATA_APP_COMPONENT_ID, KeboolaClient


@pytest.mark.asyncio
async def test_encrypt_without_project_id(keboola_client: KeboolaClient) -> None:
"""Check that the encryption client does not send any authorization headers."""
assert 'Authorization' not in keboola_client.encryption_client.raw_client.headers
assert 'X-StorageAPI-Token' not in keboola_client.encryption_client.raw_client.headers


@pytest.mark.asyncio
async def test_encrypt_string_not_equal(keboola_client: KeboolaClient) -> None:
project_id = await keboola_client.storage_client.project_id()
plaintext = 'my-plain-text'
encrypted = await keboola_client.encryption_client.encrypt(
value=plaintext,
project_id=str(project_id),
component_id=DATA_APP_COMPONENT_ID,
)
assert isinstance(encrypted, str)
assert encrypted != plaintext


@pytest.mark.asyncio
async def test_encrypt_dict_hash_keys_only(keboola_client: KeboolaClient) -> None:
project_id = await keboola_client.storage_client.project_id()
payload: dict[str, Any] = {
'#secret': 'sensitive-value',
'public': 'visible-value',
}
result = await keboola_client.encryption_client.encrypt(
value=payload,
project_id=str(project_id),
component_id=DATA_APP_COMPONENT_ID,
)
assert isinstance(result, dict)
# Values under keys beginning with '#' should be encrypted (changed)
assert result['#secret'] != payload['#secret']
# Non-secret values should remain the same
assert result['public'] == payload['public']
2 changes: 1 addition & 1 deletion integtests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext

from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.config import Config
from keboola_mcp_server.mcp import ServerState
from keboola_mcp_server.workspace import WorkspaceManager
Expand Down
2 changes: 1 addition & 1 deletion integtests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from fastmcp import Context
from mcp.types import ClientCapabilities, Implementation, InitializeRequestParams

from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.errors import tool_errors
from keboola_mcp_server.tools.doc import docs_query
from keboola_mcp_server.tools.jobs import get_job
Expand Down
2 changes: 1 addition & 1 deletion integtests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from mcp.types import TextContent

from integtests.conftest import AsyncContextClientRunner, AsyncContextServerRemoteRunner, ConfigDef
from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.config import Config
from keboola_mcp_server.server import create_server
from keboola_mcp_server.tools.components.model import Configuration
Expand Down
4 changes: 3 additions & 1 deletion integtests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
import jsonschema
import pytest

from keboola_mcp_server.client import ComponentAPIResponse, JsonDict, KeboolaClient
from keboola_mcp_server.clients.base import JsonDict
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.clients.storage import ComponentAPIResponse
from keboola_mcp_server.tools.components.model import Component
from keboola_mcp_server.tools.validation import KeboolaParametersValidator

Expand Down
2 changes: 1 addition & 1 deletion integtests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import requests
from kbcstorage.client import Client as SyncStorageClient

from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.workspace import WorkspaceManager

LOG = logging.getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion integtests/tools/components/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from mcp.server.fastmcp import Context

from integtests.conftest import ConfigDef, ProjectDef
from keboola_mcp_server.client import KeboolaClient, get_metadata_property
from keboola_mcp_server.clients.client import KeboolaClient, get_metadata_property
from keboola_mcp_server.config import Config, MetadataField
from keboola_mcp_server.links import Link
from keboola_mcp_server.server import create_server
Expand Down
2 changes: 1 addition & 1 deletion integtests/tools/flow/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import ValidationError

from integtests.conftest import ConfigDef, ProjectDef
from keboola_mcp_server.client import (
from keboola_mcp_server.clients.client import (
CONDITIONAL_FLOW_COMPONENT_ID,
ORCHESTRATOR_COMPONENT_ID,
FlowType,
Expand Down
2 changes: 1 addition & 1 deletion integtests/tools/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from mcp.server.fastmcp import Context

from integtests.conftest import ConfigDef, ProjectDef
from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.links import Link
from keboola_mcp_server.tools.components import create_config
from keboola_mcp_server.tools.jobs import JobDetail, ListJobsOutput, get_job, list_jobs, run_job
Expand Down
3 changes: 2 additions & 1 deletion integtests/tools/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from fastmcp import Context

from integtests.conftest import BucketDef, ConfigDef, TableDef
from keboola_mcp_server.client import KeboolaClient, SuggestedComponent
from keboola_mcp_server.clients.ai_service import SuggestedComponent
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.tools.search import GlobalSearchOutput, find_component_id, search

LOG = logging.getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion integtests/tools/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastmcp import Context

from integtests.conftest import BucketDef, TableDef
from keboola_mcp_server.client import KeboolaClient
from keboola_mcp_server.clients.client import KeboolaClient
from keboola_mcp_server.config import MetadataField
from keboola_mcp_server.tools.storage import (
BucketDetail,
Expand Down
5 changes: 1 addition & 4 deletions src/keboola_mcp_server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,7 @@ async def lifespan(app: Starlette):
async with sse_app.lifespan(app):
yield

app = Starlette(
middleware=[Middleware(ForwardSlashMiddleware)],
lifespan=lifespan
)
app = Starlette(middleware=[Middleware(ForwardSlashMiddleware)], lifespan=lifespan)
app.mount('/mcp', http_app)
app.mount('/sse', sse_app) # serves /sse/ and /messages
custom_routes.add_to_starlette(app)
Expand Down
Empty file.
85 changes: 85 additions & 0 deletions src/keboola_mcp_server/clients/ai_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from typing import Any, Optional, cast

from pydantic import BaseModel, Field

from keboola_mcp_server.clients.base import JsonDict, KeboolaServiceClient, RawKeboolaClient


class DocsQuestionResponse(BaseModel):
"""
The AI service response to a request to `/docs/question` endpoint.
"""

text: str = Field(description='Text of the answer to a documentation query.')
source_urls: list[str] = Field(
description='List of URLs to the sources of the answer.',
default_factory=list,
alias='sourceUrls',
)


class SuggestedComponent(BaseModel):
"""The AI service response to a /docs/suggest-component request."""

component_id: str = Field(description='The component ID.', alias='componentId')
score: float = Field(description='Score of the component suggestion.')
source: str = Field(description='Source of the component suggestion.')


class ComponentSuggestionResponse(BaseModel):
"""The AI service response to a /suggest/component request."""

components: list[SuggestedComponent] = Field(description='List of suggested components.', default_factory=list)


class AIServiceClient(KeboolaServiceClient):
"""Async client for Keboola AI Service."""

@classmethod
def create(cls, root_url: str, token: Optional[str], headers: dict[str, Any] | None = None) -> 'AIServiceClient':
"""
Creates an AIServiceClient from a Keboola Storage API token.

:param root_url: The root URL of the AI service API.
:param token: The Keboola Storage API token. If None, the client will not send any authorization header.
:param headers: Additional headers for the requests.
:return: A new instance of AIServiceClient.
"""
return cls(raw_client=RawKeboolaClient(base_api_url=root_url, api_token=token, headers=headers))

async def get_component_detail(self, component_id: str) -> JsonDict:
"""
Retrieves information about a given component.

:param component_id: The id of the component.
:return: Component details as dictionary.
"""
return cast(JsonDict, await self.get(endpoint=f'docs/components/{component_id}'))

async def docs_question(self, query: str) -> DocsQuestionResponse:
"""
Answers a question using the Keboola documentation as a source.
:param query: The query to answer.
:return: Response containing the answer and source URLs.
"""
response = await self.raw_client.post(
endpoint='docs/question',
data={'query': query},
headers={'Accept': 'application/json'},
)

return DocsQuestionResponse.model_validate(response)

async def suggest_component(self, query: str) -> ComponentSuggestionResponse:
"""
Provides list of component suggestions based on natural language query.
:param query: The query to answer.
:return: Response containing the list of suggested component IDs, their score and source.
"""
response = await self.raw_client.post(
endpoint='suggest/component',
data={'prompt': query},
headers={'Accept': 'application/json'},
)

return ComponentSuggestionResponse.model_validate(response)
Loading