Skip to content

Commit d093e9e

Browse files
authored
Merge pull request #165 from keboola/KAB-1124-add-links-to-detail-tools
Kab 1124 add links to detail tools
2 parents fe51f6c + 7716104 commit d093e9e

File tree

16 files changed

+431
-223
lines changed

16 files changed

+431
-223
lines changed

integtests/tools/components/test_tools.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
from integtests.conftest import ConfigDef
88
from keboola_mcp_server.client import KeboolaClient
99
from keboola_mcp_server.config import MetadataField
10+
from keboola_mcp_server.links import Link
1011
from keboola_mcp_server.tools.components import (
1112
ComponentType,
1213
ComponentWithConfigurations,
1314
get_component_configuration,
1415
retrieve_components_configurations,
1516
)
16-
from keboola_mcp_server.tools.components.model import ComponentConfigurationOutput, ComponentRootConfiguration
17+
from keboola_mcp_server.tools.components.model import (
18+
ComponentConfigurationOutput,
19+
ComponentRootConfiguration,
20+
RetrieveComponentsConfigurationsOutput,
21+
)
1722
from keboola_mcp_server.tools.components.tools import (
1823
create_component_root_configuration,
1924
update_component_root_configuration,
@@ -42,6 +47,10 @@ async def test_get_component_configuration(mcp_context: Context, configs: list[C
4247
assert result.root_configuration is not None
4348
assert result.root_configuration.configuration_id == config.configuration_id
4449
assert result.root_configuration.component_id == config.component_id
50+
# Check links field
51+
assert result.links, 'Links list should not be empty.'
52+
for link in result.links:
53+
assert isinstance(link, Link)
4554

4655

4756
@pytest.mark.asyncio
@@ -55,10 +64,10 @@ async def test_retrieve_components_by_ids(mcp_context: Context, configs: list[Co
5564
result = await retrieve_components_configurations(ctx=mcp_context, component_ids=component_ids)
5665

5766
# Verify result structure and content
58-
assert isinstance(result, list)
59-
assert len(result) == len(component_ids)
67+
assert isinstance(result, RetrieveComponentsConfigurationsOutput)
68+
assert len(result.components_with_configurations) == len(component_ids)
6069

61-
for item in result:
70+
for item in result.components_with_configurations:
6271
assert isinstance(item, ComponentWithConfigurations)
6372
assert item.component.component_id in component_ids
6473

@@ -79,11 +88,11 @@ async def test_retrieve_components_by_types(mcp_context: Context, configs: list[
7988

8089
result = await retrieve_components_configurations(ctx=mcp_context, component_types=component_types)
8190

82-
assert isinstance(result, list)
91+
assert isinstance(result, RetrieveComponentsConfigurationsOutput)
8392
# Currently, we only have extractor components in the project
84-
assert len(result) == len(component_ids)
93+
assert len(result.components_with_configurations) == len(component_ids)
8594

86-
for item in result:
95+
for item in result.components_with_configurations:
8796
assert isinstance(item, ComponentWithConfigurations)
8897
assert item.component.component_type == 'extractor'
8998

@@ -108,7 +117,7 @@ async def test_create_component_root_configuration(mcp_context: Context, configs
108117
description=test_description,
109118
component_id=component_id,
110119
parameters=test_parameters,
111-
storage=test_storage
120+
storage=test_storage,
112121
)
113122
try:
114123
assert isinstance(created_config, ComponentRootConfiguration)
@@ -122,8 +131,7 @@ async def test_create_component_root_configuration(mcp_context: Context, configs
122131
# Verify the configuration exists in the backend by fetching it
123132
client = KeboolaClient.from_state(mcp_context.session.state)
124133
config_detail = await client.storage_client.configuration_detail(
125-
component_id=component_id,
126-
configuration_id=created_config.configuration_id
134+
component_id=component_id, configuration_id=created_config.configuration_id
127135
)
128136

129137
assert config_detail['name'] == test_name
@@ -132,8 +140,7 @@ async def test_create_component_root_configuration(mcp_context: Context, configs
132140

133141
# Verify the metadata - check that KBC.MCP.createdBy is set to 'true'
134142
metadata = await client.storage_client.configuration_metadata_get(
135-
component_id=component_id,
136-
configuration_id=created_config.configuration_id
143+
component_id=component_id, configuration_id=created_config.configuration_id
137144
)
138145

139146
# Convert metadata list to dictionary for easier checking
@@ -166,7 +173,7 @@ async def test_update_component_root_configuration(mcp_context: Context, configs
166173
description='Initial test configuration created by automated test',
167174
component_id=component_id,
168175
parameters={'initial_param': 'initial_value'},
169-
storage={'input': {'tables': [{'source': 'in.c-bucket.table', 'destination': 'input.csv'}]}}
176+
storage={'input': {'tables': [{'source': 'in.c-bucket.table', 'destination': 'input.csv'}]}},
170177
)
171178
assert created_config.configuration_id is not None
172179
client = KeboolaClient.from_state(mcp_context.session.state)
@@ -187,7 +194,7 @@ async def test_update_component_root_configuration(mcp_context: Context, configs
187194
component_id=component_id,
188195
configuration_id=created_config.configuration_id,
189196
parameters=updated_parameters,
190-
storage=updated_storage
197+
storage=updated_storage,
191198
)
192199

193200
assert isinstance(updated_config, ComponentRootConfiguration)
@@ -201,8 +208,7 @@ async def test_update_component_root_configuration(mcp_context: Context, configs
201208

202209
# Verify the configuration exists in the backend by fetching it
203210
config_detail = await client.storage_client.configuration_detail(
204-
component_id=component_id,
205-
configuration_id=updated_config.configuration_id
211+
component_id=component_id, configuration_id=updated_config.configuration_id
206212
)
207213

208214
assert config_detail['name'] == updated_name
@@ -218,8 +224,7 @@ async def test_update_component_root_configuration(mcp_context: Context, configs
218224

219225
# Verify the metadata - check that KBC.MCP.updatedBy.version.{version} is set to 'true'
220226
metadata = await client.storage_client.configuration_metadata_get(
221-
component_id=component_id,
222-
configuration_id=updated_config.configuration_id
227+
component_id=component_id, configuration_id=updated_config.configuration_id
223228
)
224229

225230
assert isinstance(metadata, list)

integtests/tools/test_flow.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from keboola_mcp_server.config import MetadataField
77
from keboola_mcp_server.tools.flow import (
88
FlowToolResponse,
9+
RetrieveFlowsOutput,
910
create_flow,
1011
get_flow_detail,
1112
retrieve_flows,
@@ -63,9 +64,9 @@ async def test_create_and_retrieve_flow(mcp_context: Context, configs: list[Conf
6364
assert created.success is True
6465
assert len(created.links) == 3
6566

66-
flows = await retrieve_flows(mcp_context)
67-
assert any(f.name == flow_name for f in flows)
68-
found = [f for f in flows if f.id == flow_id][0]
67+
result = await retrieve_flows(mcp_context)
68+
assert any(f.name == flow_name for f in result.flows)
69+
found = [f for f in result.flows if f.id == flow_id][0]
6970
detail = await get_flow_detail(mcp_context, configuration_id=found.id)
7071
assert detail.phases[0].name == 'Extract'
7172
assert detail.phases[1].name == 'Transform'
@@ -164,7 +165,7 @@ async def test_retrieve_flows_empty(mcp_context: Context) -> None:
164165
:return: None
165166
"""
166167
flows = await retrieve_flows(mcp_context)
167-
assert isinstance(flows, list)
168+
assert isinstance(flows, RetrieveFlowsOutput)
168169

169170

170171
@pytest.mark.asyncio

integtests/tools/test_storage.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import csv
2-
import logging
32

43
import pytest
54
from fastmcp import Context
@@ -9,6 +8,8 @@
98
from keboola_mcp_server.config import MetadataField
109
from keboola_mcp_server.tools.storage import (
1110
BucketDetail,
11+
RetrieveBucketsOutput,
12+
RetrieveBucketTablesOutput,
1213
TableDetail,
1314
get_bucket_detail,
1415
get_table_detail,
@@ -18,19 +19,17 @@
1819
update_table_description,
1920
)
2021

21-
LOG = logging.getLogger(__name__)
22-
2322

2423
@pytest.mark.asyncio
2524
async def test_retrieve_buckets(mcp_context: Context, buckets: list[BucketDef]):
2625
"""Tests that `retrieve_buckets` returns a list of `BucketDetail` instances."""
2726
result = await retrieve_buckets(mcp_context)
2827

29-
assert isinstance(result, list)
30-
for item in result:
28+
assert isinstance(result, RetrieveBucketsOutput)
29+
for item in result.buckets:
3130
assert isinstance(item, BucketDetail)
3231

33-
assert len(result) == len(buckets)
32+
assert len(result.buckets) == len(buckets)
3433

3534

3635
@pytest.mark.asyncio
@@ -72,16 +71,16 @@ async def test_retrieve_bucket_tables(mcp_context: Context, tables: list[TableDe
7271
for bucket in buckets:
7372
result = await retrieve_bucket_tables(bucket.bucket_id, mcp_context)
7473

75-
assert isinstance(result, list)
76-
for item in result:
74+
assert isinstance(result, RetrieveBucketTablesOutput)
75+
for item in result.tables:
7776
assert isinstance(item, TableDetail)
7877

7978
# Verify the count matches expected tables for this bucket
8079
expected_tables = tables_by_bucket.get(bucket.bucket_id, [])
81-
assert len(result) == len(expected_tables)
80+
assert len(result.tables) == len(expected_tables)
8281

8382
# Verify table IDs match
84-
result_table_ids = {table.id for table in result}
83+
result_table_ids = {table.id for table in result.tables}
8584
expected_table_ids = {table.table_id for table in expected_tables}
8685
assert result_table_ids == expected_table_ids
8786

@@ -91,10 +90,10 @@ async def test_update_bucket_description(mcp_context: Context, buckets: list[Buc
9190
"""Tests that `update_bucket_description` updates the description of a bucket."""
9291
bucket = buckets[0]
9392
md_id: str | None = None
93+
client = KeboolaClient.from_state(mcp_context.session.state)
9494
try:
9595
result = await update_bucket_description(bucket.bucket_id, 'New Description', mcp_context)
9696
assert result.description == 'New Description'
97-
client = KeboolaClient.from_state(mcp_context.session.state)
9897

9998
metadata = await client.storage_client.bucket_metadata_get(bucket.bucket_id)
10099
metadata_entry = next((entry for entry in metadata if entry.get('key') == MetadataField.DESCRIPTION), None)
@@ -111,10 +110,10 @@ async def test_update_table_description(mcp_context: Context, tables: list[Table
111110
"""Tests that `update_table_description` updates the description of a table."""
112111
table = tables[0]
113112
md_id: str | None = None
113+
client = KeboolaClient.from_state(mcp_context.session.state)
114114
try:
115115
result = await update_table_description(table.table_id, 'New Description', mcp_context)
116116
assert result.description == 'New Description'
117-
client = KeboolaClient.from_state(mcp_context.session.state)
118117

119118
metadata = await client.storage_client.table_metadata_get(table.table_id)
120119
metadata_entry = next((entry for entry in metadata if entry.get('key') == MetadataField.DESCRIPTION), None)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "keboola-mcp-server"
7-
version = "1.2.1"
7+
version = "1.3.0"
88
description = "MCP server for interacting with Keboola Connection"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/keboola_mcp_server/links.py

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,122 @@ class Link(BaseModel):
1212
title: str = Field(..., description='The name of the URL.')
1313
url: str = Field(..., description='The URL.')
1414

15+
@classmethod
16+
def detail(cls, title: str, url: str) -> 'Link':
17+
return cls(type='ui-detail', title=title, url=url)
1518

16-
class ProjectLinksManager:
19+
@classmethod
20+
def dashboard(cls, title: str, url: str) -> 'Link':
21+
return cls(type='ui-dashboard', title=title, url=url)
1722

23+
@classmethod
24+
def docs(cls, title: str, url: str) -> 'Link':
25+
return cls(type='docs', title=title, url=url)
26+
27+
28+
class ProjectLinksManager:
1829
FLOW_DOCUMENTATION_URL = 'https://help.keboola.com/flows/'
1930

2031
def __init__(self, base_url: str, project_id: str):
21-
self.base_url = base_url
22-
self.project_id = project_id
32+
self._base_url = base_url
33+
self._project_id = project_id
2334

2435
@classmethod
2536
async def from_client(cls, client: KeboolaClient) -> 'ProjectLinksManager':
2637
base_url = client.storage_client.base_api_url
2738
project_id = await client.storage_client.project_id()
28-
return ProjectLinksManager(base_url, project_id)
39+
return cls(base_url=base_url, project_id=project_id)
2940

30-
def get_flow_url(self, flow_id: str | int) -> str:
31-
"""Get the UI detail URL for a specific flow."""
32-
return f'{self.base_url}/admin/projects/{self.project_id}/flows/{flow_id}'
41+
def _url(self, path: str) -> str:
42+
return f'{self._base_url}/admin/projects/{self._project_id}/{path}'
3343

34-
def get_flows_dashboard_url(self) -> str:
35-
"""Get the UI dashboard URL for all flows in a project."""
36-
return f'{self.base_url}/admin/projects/{self.project_id}/flows'
37-
38-
def get_project_url(self) -> str:
39-
"""Return the UI URL for accessing the project."""
40-
return f'{self.base_url}/admin/projects/{self.project_id}'
44+
# --- Project ---
45+
def get_project_detail_link(self) -> Link:
46+
return Link.detail(title='Project Dashboard', url=self._url(''))
4147

4248
def get_project_links(self) -> list[Link]:
43-
"""Return a list of relevant links for a project."""
44-
project_url = self.get_project_url()
45-
return [Link(type='ui-detail', title='Project Dashboard', url=project_url)]
49+
return [self.get_project_detail_link()]
50+
51+
# --- Flows ---
52+
def get_flow_detail_link(self, flow_id: str | int, flow_name: str) -> Link:
53+
return Link.detail(title=f'Flow: {flow_name}', url=self._url(f'flows/{flow_id}'))
54+
55+
def get_flows_dashboard_link(self) -> Link:
56+
return Link.dashboard(title='Flows in the project', url=self._url('flows'))
57+
58+
def get_flows_docs_link(self) -> Link:
59+
return Link.docs(title='Documentation for Keboola Flows', url=self.FLOW_DOCUMENTATION_URL)
4660

4761
def get_flow_links(self, flow_id: str | int, flow_name: str) -> list[Link]:
48-
"""Get a list of relevant links for a flow, including detail, dashboard, and documentation."""
49-
flow_detail_url = Link(type='ui-detail', title=f'Flow: {flow_name}', url=self.get_flow_url(flow_id))
50-
flows_dashboard_url = Link(
51-
type='ui-dashboard', title='Flows in the project', url=self.get_flows_dashboard_url()
62+
return [
63+
self.get_flow_detail_link(flow_id, flow_name),
64+
self.get_flows_dashboard_link(),
65+
self.get_flows_docs_link(),
66+
]
67+
68+
# --- Components ---
69+
def get_component_config_link(self, component_id: str, configuration_id: str, configuration_name: str) -> Link:
70+
return Link.detail(
71+
title=f'Configuration: {configuration_name}', url=self._url(f'components/{component_id}/{configuration_id}')
72+
)
73+
74+
def get_component_configs_dashboard_link(self, component_id: str, component_name: str) -> Link:
75+
return Link.dashboard(
76+
title=f'{component_name} Configurations Dashboard', url=self._url(f'components/{component_id}')
5277
)
53-
documentation_url = Link(type='docs', title='Documentation for Keboola Flows', url=self.FLOW_DOCUMENTATION_URL)
54-
return [flow_detail_url, flows_dashboard_url, documentation_url]
78+
79+
def get_used_components_link(
80+
self
81+
) -> Link:
82+
return Link.dashboard(
83+
title='Used Components Dashboard', url=self._url('components/configurations')
84+
)
85+
86+
def get_component_configuration_links(
87+
self, component_id: str, configuration_id: str, configuration_name: str
88+
) -> list[Link]:
89+
return [
90+
self.get_component_config_link(
91+
component_id=component_id, configuration_id=configuration_id, configuration_name=configuration_name
92+
),
93+
self.get_component_configs_dashboard_link(component_id=component_id, component_name=component_id),
94+
]
95+
96+
# --- Transformations ---
97+
def get_transformations_dashboard_link(self) -> Link:
98+
return Link.dashboard(
99+
title='Transformations dashboard', url=self._url('transformations-v2')
100+
)
101+
102+
# --- Jobs ---
103+
def get_job_detail_link(self, job_id: str) -> Link:
104+
return Link.detail(title=f'Job: {job_id}', url=self._url(f'queue/{job_id}'))
105+
106+
def get_jobs_dashboard_link(self) -> Link:
107+
return Link.dashboard(title='Jobs in the project', url=self._url('queue'))
108+
109+
def get_job_links(self, job_id: str) -> list[Link]:
110+
return [self.get_job_detail_link(job_id), self.get_jobs_dashboard_link()]
111+
112+
# --- Buckets ---
113+
def get_bucket_detail_link(self, bucket_id: str, bucket_name: str) -> Link:
114+
return Link.detail(title=f'Bucket: {bucket_name}', url=self._url(f'storage/{bucket_id}'))
115+
116+
def get_bucket_dashboard_link(self) -> Link:
117+
return Link.dashboard(title='Buckets in the project', url=self._url('storage'))
118+
119+
def get_bucket_links(self, bucket_id: str, bucket_name: str) -> list[Link]:
120+
return [
121+
self.get_bucket_detail_link(bucket_id, bucket_name),
122+
self.get_bucket_dashboard_link(),
123+
]
124+
125+
# --- Tables ---
126+
def get_table_detail_link(self, bucket_id: str, table_name: str) -> Link:
127+
return Link.detail(title=f'Table: {table_name}', url=self._url(f'storage/{bucket_id}/table/{table_name}'))
128+
129+
def get_table_links(self, bucket_id: str, table_name: str) -> list[Link]:
130+
return [
131+
self.get_table_detail_link(bucket_id, table_name),
132+
self.get_bucket_detail_link(bucket_id=bucket_id, bucket_name=bucket_id),
133+
]

0 commit comments

Comments
 (0)