Skip to content

Commit 7d7abf3

Browse files
committed
feat: added simple resources and better prompts [2025-08-01]
1 parent 29757fa commit 7d7abf3

File tree

8 files changed

+270
-2
lines changed

8 files changed

+270
-2
lines changed

src/api_service/os_api.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,6 @@ async def make_request(
283283
timeout=timeout,
284284
) as response:
285285
if response.status >= 400:
286-
# Sanitize error response text
287286
error_text = await response.text()
288287
sanitized_error = self._sanitise_api_key(error_text)
289288
error_message = (
@@ -311,3 +310,72 @@ async def make_request(
311310
raise RuntimeError(
312311
"Unreachable: make_request exited retry loop without returning or raising"
313312
)
313+
314+
async def make_request_no_auth(
315+
self,
316+
url: str,
317+
params: Optional[Dict[str, Any]] = None,
318+
max_retries: int = 2,
319+
) -> str:
320+
"""
321+
Make a request without authentication (for public endpoints like documentation).
322+
323+
Args:
324+
url: Full URL to request
325+
params: Additional query parameters
326+
max_retries: Maximum number of retries for transient errors
327+
328+
Returns:
329+
Response text (not JSON parsed)
330+
"""
331+
await self.initialise()
332+
333+
if self.session is None:
334+
raise ValueError("Session not initialised")
335+
336+
current_time = asyncio.get_event_loop().time()
337+
elapsed = current_time - self.last_request_time
338+
if elapsed < self.request_delay:
339+
await asyncio.sleep(self.request_delay - elapsed)
340+
341+
request_params = params or {}
342+
headers = {"User-Agent": self.user_agent}
343+
344+
logger.info(f"Requesting URL (no auth): {url}")
345+
346+
for attempt in range(1, max_retries + 1):
347+
try:
348+
self.last_request_time = asyncio.get_event_loop().time()
349+
350+
timeout = aiohttp.ClientTimeout(total=30.0)
351+
async with self.session.get(
352+
url,
353+
params=request_params,
354+
headers=headers,
355+
timeout=timeout,
356+
) as response:
357+
if response.status >= 400:
358+
error_text = await response.text()
359+
error_message = f"HTTP Error: {response.status} - {error_text}"
360+
logger.error(f"Error: {error_message}")
361+
raise ValueError(error_message)
362+
363+
return await response.text()
364+
365+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
366+
if attempt == max_retries:
367+
error_message = (
368+
f"Request failed after {max_retries} attempts: {str(e)}"
369+
)
370+
logger.error(f"Error: {error_message}")
371+
raise ValueError(error_message)
372+
else:
373+
await asyncio.sleep(0.7)
374+
except Exception as e:
375+
error_message = f"Request failed: {str(e)}"
376+
logger.error(f"Error: {error_message}")
377+
raise ValueError(error_message)
378+
379+
raise RuntimeError(
380+
"Unreachable: make_request_no_auth exited retry loop without returning or raising"
381+
)

src/api_service/protocols.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ async def make_request(
2626
"""Make a request to an API endpoint"""
2727
...
2828

29+
async def make_request_no_auth(
30+
self,
31+
url: str,
32+
params: Optional[Dict[str, Any]] = None,
33+
max_retries: int = 2,
34+
) -> str:
35+
"""Make a request without authentication"""
36+
...
37+
2938
async def cache_openapi_spec(self):
3039
"""Cache the OpenAPI spec"""
3140
...

src/mcp_service/os_service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from workflow_generator.workflow_planner import WorkflowPlanner
1414
from utils.logging_config import get_logger
1515
from models import Collection
16+
from mcp_service.resources import OSDocumentationResources
17+
from mcp_service.prompts import OSWorkflowPrompts
1618

1719
logger = get_logger(__name__)
1820

@@ -37,6 +39,13 @@ def __init__(
3739
self.workflow_planner: Optional[WorkflowPlanner] = None
3840
self.guardrails = ToolGuardrails()
3941
self.register_tools()
42+
self.register_resources()
43+
self.register_prompts()
44+
45+
def register_resources(self) -> None:
46+
"""Register all MCP resources"""
47+
doc_resources = OSDocumentationResources(self.mcp, self.api_client)
48+
doc_resources.register_all()
4049

4150
def register_tools(self) -> None:
4251
"""Register all MCP tools with guardrails and middleware"""
@@ -76,6 +85,11 @@ def apply_middleware(func):
7685
apply_middleware(self.get_prompt_templates)
7786
)
7887

88+
def register_prompts(self) -> None:
89+
"""Register all MCP prompts"""
90+
workflow_prompts = OSWorkflowPrompts(self.mcp)
91+
workflow_prompts.register_all()
92+
7993
def run(self) -> None:
8094
"""Run the MCP service"""
8195
try:

src/mcp_service/prompts.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import List
2+
from mcp.types import PromptMessage, TextContent
3+
from prompt_templates.prompt_templates import PROMPT_TEMPLATES
4+
from utils.logging_config import get_logger
5+
6+
logger = get_logger(__name__)
7+
8+
9+
class OSWorkflowPrompts:
10+
"""Handles registration of OS NGD workflow prompts"""
11+
12+
def __init__(self, mcp_service):
13+
self.mcp = mcp_service
14+
15+
def register_all(self) -> None:
16+
"""Register all workflow prompts"""
17+
self._register_analysis_prompts()
18+
self._register_general_prompts()
19+
20+
def _register_analysis_prompts(self) -> None:
21+
"""Register analysis workflow prompts"""
22+
23+
@self.mcp.prompt()
24+
def usrn_breakdown_analysis(usrn: str) -> List[PromptMessage]:
25+
"""Generate a step-by-step USRN breakdown workflow"""
26+
template = PROMPT_TEMPLATES["usrn_breakdown"].format(usrn=usrn)
27+
28+
return [
29+
PromptMessage(
30+
role="user",
31+
content=TextContent(
32+
type="text",
33+
text=f"As an expert in OS NGD API workflows and transport network analysis, {template}"
34+
)
35+
)
36+
]
37+
38+
def _register_general_prompts(self) -> None:
39+
"""Register general OS NGD guidance prompts"""
40+
41+
@self.mcp.prompt()
42+
def collection_query_guidance(collection_id: str, query_type: str = "features") -> List[PromptMessage]:
43+
"""Generate guidance for querying OS NGD collections"""
44+
return [
45+
PromptMessage(
46+
role="user",
47+
content=TextContent(
48+
type="text",
49+
text=f"As an OS NGD API expert, guide me through querying the '{collection_id}' collection for {query_type}. "
50+
f"Include: 1) Available filters, 2) Best practices for bbox queries, "
51+
f"3) CRS considerations, 4) Example queries with proper syntax."
52+
)
53+
)
54+
]
55+
56+
@self.mcp.prompt()
57+
def workflow_planning(user_request: str, data_theme: str = "transport") -> List[PromptMessage]:
58+
"""Generate a workflow plan for complex OS NGD queries"""
59+
return [
60+
PromptMessage(
61+
role="user",
62+
content=TextContent(
63+
type="text",
64+
text=f"As a geospatial workflow planner, create a detailed workflow plan for: '{user_request}'. "
65+
f"Focus on {data_theme} theme data. Include: "
66+
f"1) Collection selection rationale, "
67+
f"2) Query sequence with dependencies, "
68+
f"3) Filter strategies, "
69+
f"4) Error handling considerations."
70+
)
71+
)
72+
]

src/mcp_service/protocols.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ def tool(self) -> Callable[..., Any]:
99
"""Register a function as an MCP tool"""
1010
...
1111

12+
def resource(
13+
self,
14+
uri: str,
15+
*,
16+
name: str | None = None,
17+
title: str | None = None,
18+
description: str | None = None,
19+
mime_type: str | None = None,
20+
) -> Callable[[Any], Any]:
21+
"""Register a function as an MCP resource"""
22+
...
23+
1224
def run(self) -> None:
1325
"""Run the MCP service"""
1426
...

src/mcp_service/resources.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""MCP Resources for OS NGD documentation"""
2+
3+
import json
4+
import time
5+
from models import NGDAPIEndpoint
6+
from utils.logging_config import get_logger
7+
8+
logger = get_logger(__name__)
9+
10+
# TODO: Do this for
11+
class OSDocumentationResources:
12+
"""Handles registration of OS NGD documentation resources"""
13+
14+
def __init__(self, mcp_service, api_client):
15+
self.mcp = mcp_service
16+
self.api_client = api_client
17+
18+
def register_all(self) -> None:
19+
"""Register all documentation resources"""
20+
self._register_transport_network_resources()
21+
# Future: self._register_land_resources()
22+
# Future: self._register_building_resources()
23+
24+
def _register_transport_network_resources(self) -> None:
25+
"""Register transport network documentation resources"""
26+
27+
@self.mcp.resource("os-docs://street")
28+
async def street_docs() -> str:
29+
return await self._fetch_doc_resource(
30+
"street", NGDAPIEndpoint.MARKDOWN_STREET.value
31+
)
32+
33+
@self.mcp.resource("os-docs://road")
34+
async def road_docs() -> str:
35+
return await self._fetch_doc_resource(
36+
"road", NGDAPIEndpoint.MARKDOWN_ROAD.value
37+
)
38+
39+
@self.mcp.resource("os-docs://tram-on-road")
40+
async def tram_on_road_docs() -> str:
41+
return await self._fetch_doc_resource(
42+
"tram-on-road", NGDAPIEndpoint.TRAM_ON_ROAD.value
43+
)
44+
45+
@self.mcp.resource("os-docs://road-node")
46+
async def road_node_docs() -> str:
47+
return await self._fetch_doc_resource(
48+
"road-node", NGDAPIEndpoint.ROAD_NODE.value
49+
)
50+
51+
@self.mcp.resource("os-docs://road-link")
52+
async def road_link_docs() -> str:
53+
return await self._fetch_doc_resource(
54+
"road-link", NGDAPIEndpoint.ROAD_LINK.value
55+
)
56+
57+
@self.mcp.resource("os-docs://road-junction")
58+
async def road_junction_docs() -> str:
59+
return await self._fetch_doc_resource(
60+
"road-junction", NGDAPIEndpoint.ROAD_JUNCTION.value
61+
)
62+
63+
async def _fetch_doc_resource(self, feature_type: str, url: str) -> str:
64+
"""Generic method to fetch documentation resources"""
65+
try:
66+
content = await self.api_client.make_request_no_auth(url)
67+
68+
return json.dumps(
69+
{
70+
"feature_type": feature_type,
71+
"content": content,
72+
"content_type": "markdown",
73+
"source_url": url,
74+
"timestamp": time.time(),
75+
}
76+
)
77+
78+
except Exception as e:
79+
logger.error(f"Error fetching {feature_type} documentation: {e}")
80+
return json.dumps({"error": str(e), "feature_type": feature_type})

src/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ class NGDAPIEndpoint(Enum):
2828
LINKED_IDENTIFIERS_BASE_PATH = "https://api.os.uk/search/links/v1/{}"
2929
LINKED_IDENTIFIERS = LINKED_IDENTIFIERS_BASE_PATH.format("identifierTypes/{}/{}")
3030

31+
# Markdown Resources
32+
MARKDOWN_BASE_PATH = "https://docs.os.uk/osngd/data-structure/{}"
33+
MARKDOWN_STREET = MARKDOWN_BASE_PATH.format("transport/transport-network/street.md")
34+
MARKDOWN_ROAD = MARKDOWN_BASE_PATH.format("transport/transport-network/road.md")
35+
TRAM_ON_ROAD = MARKDOWN_BASE_PATH.format(
36+
"transport/transport-network/tram-on-road.md"
37+
)
38+
ROAD_NODE = MARKDOWN_BASE_PATH.format("transport/transport-network/road-node.md")
39+
ROAD_LINK = MARKDOWN_BASE_PATH.format("transport/transport-network/road-link.md")
40+
ROAD_JUNCTION = MARKDOWN_BASE_PATH.format(
41+
"transport/transport-network/road-junction.md"
42+
)
43+
3144
# Places API Endpoints
3245
# PLACES_BASE_PATH = "https://api.os.uk/search/places/v1/{}"
3346
# PLACES_UPRN = PLACES_BASE_PATH.format("uprn")

src/prompt_templates/prompt_templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"Step 1: GET /collections/trn-ntwk-street-1/items?filter=usrn='{usrn}' to get street details. "
66
"Step 2: Use Street geometry bbox to query Road Links: GET /collections/trn-ntwk-roadlink-4/items?bbox=[street_bbox] "
77
"Step 3: Filter Road Links by Street reference using properties.street_ref matching street feature ID. "
8-
"Step 4: For each Road Link: GET /collections/trn-ntwk-roadnode-1/items?filter=roadlink_ref='{roadlink_id}' "
8+
"Step 4: For each Road Link: GET /collections/trn-ntwk-roadnode-1/items?filter=roadlink_ref='roadlink_id' "
99
"Step 5: Set crs=EPSG:27700 for British National Grid coordinates. "
1010
"Return: Complete breakdown of USRN into constituent Road Links with node connections."
1111
),

0 commit comments

Comments
 (0)