cited-mcp 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ env/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .pytest_cache/
15
+ *.so
16
+ .coverage
17
+ htmlcov/
18
+ .env
19
+ ~/.cited/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: cited-mcp
3
+ Version: 0.3.2
4
+ Summary: MCP server for the Cited GEO platform
5
+ Author-email: Cited <support@youcited.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: cited-core>=0.3.2
9
+ Requires-Dist: mcp>=1.26.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.10; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.21; extra == 'dev'
14
+ Requires-Dist: ruff>=0.4; extra == 'dev'
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cited-mcp"
7
+ version = "0.3.2"
8
+ description = "MCP server for the Cited GEO platform"
9
+ license = "LicenseRef-Proprietary"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Cited", email = "support@youcited.com" }]
12
+ dependencies = [
13
+ "cited-core>=0.3.2",
14
+ "mcp>=1.26.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ cited-mcp = "cited_mcp:run_server"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "respx>=0.21",
24
+ "ruff>=0.4",
25
+ "mypy>=1.10",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/cited_mcp"]
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ from cited_core import __version__ as __version__ # noqa: F401
4
+ from cited_mcp.server import run_server as run_server # noqa: F401
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from cited_mcp import run_server
4
+
5
+ run_server()
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from cited_core.api.client import CitedClient
6
+
7
+
8
+ @dataclass
9
+ class CitedContext:
10
+ client: CitedClient
11
+ env: str
12
+ api_url: str
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from cited_core.api.client import CitedClient
10
+ from cited_core.auth.store import TokenStore
11
+ from cited_core.config.manager import ConfigManager
12
+ from cited_mcp.context import CitedContext
13
+
14
+
15
+ @asynccontextmanager
16
+ async def cited_lifespan(server: FastMCP) -> AsyncIterator[CitedContext]:
17
+ """Set up CitedClient for the MCP server lifetime."""
18
+ config = ConfigManager()
19
+ env_override = os.environ.get("CITED_ENV")
20
+ env = config.get_environment(override=env_override)
21
+ api_url = config.get_api_url(env_override=env_override)
22
+
23
+ token = os.environ.get("CITED_TOKEN") or TokenStore().get_token(env)
24
+ agent_api_key = os.environ.get("CITED_AGENT_API_KEY") or config.get("agent_api_key")
25
+
26
+ client = CitedClient(
27
+ base_url=api_url,
28
+ token=token,
29
+ agent_api_key=agent_api_key,
30
+ )
31
+ try:
32
+ yield CitedContext(client=client, env=env, api_url=api_url)
33
+ finally:
34
+ client.close()
35
+
36
+
37
+ mcp = FastMCP(
38
+ "cited",
39
+ instructions="Cited GEO platform — audit, optimize, and monitor AI search presence",
40
+ lifespan=cited_lifespan,
41
+ )
42
+
43
+ # Import tool modules to trigger @mcp.tool() registrations
44
+ import cited_mcp.tools.auth # noqa: I001, E402, F401
45
+ import cited_mcp.tools.audit # noqa: E402, F401
46
+ import cited_mcp.tools.business # noqa: E402, F401
47
+ import cited_mcp.tools.job # noqa: E402, F401
48
+ import cited_mcp.tools.recommend # noqa: E402, F401
49
+ import cited_mcp.tools.solution # noqa: E402, F401
50
+
51
+
52
+ def run_server() -> None:
53
+ """Start the MCP server on stdio transport."""
54
+ mcp.run(transport="stdio")
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.errors import CitedAPIError
8
+ from cited_mcp.context import CitedContext
9
+
10
+
11
+ def _get_ctx(ctx: Context[Any, CitedContext, Any]) -> CitedContext:
12
+ """Extract CitedContext from MCP lifespan context."""
13
+ lc: CitedContext = ctx.request_context.lifespan_context
14
+ return lc
15
+
16
+
17
+ def _auth_check(cited_ctx: CitedContext) -> dict[str, Any] | None:
18
+ """Return error dict if not authenticated, else None."""
19
+ if cited_ctx.client.token is None:
20
+ return {
21
+ "error": True,
22
+ "message": "Not authenticated. Use the 'login' tool or set CITED_TOKEN env var.",
23
+ }
24
+ return None
25
+
26
+
27
+ def _api_error_response(e: CitedAPIError) -> dict[str, Any]:
28
+ """Convert CitedAPIError to a structured error dict."""
29
+ return {
30
+ "error": True,
31
+ "status_code": e.status_code,
32
+ "message": e.message,
33
+ }
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.api import endpoints
8
+ from cited_core.errors import CitedAPIError
9
+ from cited_mcp.context import CitedContext
10
+ from cited_mcp.server import mcp
11
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
12
+
13
+
14
+ @mcp.tool()
15
+ async def list_audit_templates(ctx: Context[Any, CitedContext, Any]) -> Any:
16
+ """List all audit templates (named audits) for the user."""
17
+ cited_ctx = _get_ctx(ctx)
18
+ if err := _auth_check(cited_ctx):
19
+ return err
20
+ try:
21
+ return cited_ctx.client.get(endpoints.NAMED_AUDITS)
22
+ except CitedAPIError as e:
23
+ return _api_error_response(e)
24
+
25
+
26
+ @mcp.tool()
27
+ async def get_audit_template(ctx: Context[Any, CitedContext, Any], named_audit_id: str) -> Any:
28
+ """Get a specific audit template by ID."""
29
+ cited_ctx = _get_ctx(ctx)
30
+ if err := _auth_check(cited_ctx):
31
+ return err
32
+ try:
33
+ return cited_ctx.client.get(
34
+ endpoints.NAMED_AUDIT.format(named_audit_id=named_audit_id)
35
+ )
36
+ except CitedAPIError as e:
37
+ return _api_error_response(e)
38
+
39
+
40
+ @mcp.tool()
41
+ async def create_audit_template(
42
+ ctx: Context[Any, CitedContext, Any],
43
+ name: str,
44
+ business_id: str,
45
+ description: str | None = None,
46
+ questions: list[str] | None = None,
47
+ ) -> Any:
48
+ """Create a new audit template.
49
+
50
+ Args:
51
+ ctx: MCP context
52
+ name: Template name
53
+ business_id: Business ID to associate with
54
+ description: Optional template description
55
+ questions: Optional list of audit questions
56
+ """
57
+ cited_ctx = _get_ctx(ctx)
58
+ if err := _auth_check(cited_ctx):
59
+ return err
60
+ payload: dict[str, Any] = {"name": name, "business_id": business_id}
61
+ if description is not None:
62
+ payload["description"] = description
63
+ if questions is not None:
64
+ payload["questions"] = questions
65
+ try:
66
+ return cited_ctx.client.post(endpoints.NAMED_AUDITS, json=payload)
67
+ except CitedAPIError as e:
68
+ return _api_error_response(e)
69
+
70
+
71
+ @mcp.tool()
72
+ async def update_audit_template(
73
+ ctx: Context[Any, CitedContext, Any],
74
+ named_audit_id: str,
75
+ name: str | None = None,
76
+ description: str | None = None,
77
+ questions: list[str] | None = None,
78
+ ) -> Any:
79
+ """Update an audit template. Questions replace ALL existing questions if provided.
80
+
81
+ Args:
82
+ ctx: MCP context
83
+ named_audit_id: Template ID to update
84
+ name: New template name
85
+ description: New description
86
+ questions: New list of questions (replaces all existing questions). Omit to keep existing.
87
+ """
88
+ cited_ctx = _get_ctx(ctx)
89
+ if err := _auth_check(cited_ctx):
90
+ return err
91
+ payload: dict[str, Any] = {}
92
+ if name is not None:
93
+ payload["name"] = name
94
+ if description is not None:
95
+ payload["description"] = description
96
+ if questions is not None:
97
+ payload["questions"] = questions
98
+ try:
99
+ return cited_ctx.client.put(
100
+ endpoints.NAMED_AUDIT.format(named_audit_id=named_audit_id), json=payload
101
+ )
102
+ except CitedAPIError as e:
103
+ return _api_error_response(e)
104
+
105
+
106
+ @mcp.tool()
107
+ async def delete_audit_template(
108
+ ctx: Context[Any, CitedContext, Any], named_audit_id: str
109
+ ) -> Any:
110
+ """Delete an audit template.
111
+
112
+ Args:
113
+ ctx: MCP context
114
+ named_audit_id: Template ID to delete
115
+ """
116
+ cited_ctx = _get_ctx(ctx)
117
+ if err := _auth_check(cited_ctx):
118
+ return err
119
+ try:
120
+ cited_ctx.client.delete(
121
+ endpoints.NAMED_AUDIT.format(named_audit_id=named_audit_id)
122
+ )
123
+ return {"success": True, "message": f"Template {named_audit_id} deleted"}
124
+ except CitedAPIError as e:
125
+ return _api_error_response(e)
126
+
127
+
128
+ @mcp.tool()
129
+ async def start_audit(
130
+ ctx: Context[Any, CitedContext, Any],
131
+ named_audit_id: str,
132
+ business_id: str | None = None,
133
+ ) -> Any:
134
+ """Start an audit using a template. Returns a job_id to poll for status.
135
+
136
+ Args:
137
+ ctx: MCP context
138
+ named_audit_id: The audit template ID to run
139
+ business_id: Optional business ID override
140
+ """
141
+ cited_ctx = _get_ctx(ctx)
142
+ if err := _auth_check(cited_ctx):
143
+ return err
144
+ payload: dict[str, Any] = {"named_audit_id": named_audit_id}
145
+ if business_id is not None:
146
+ payload["business_id"] = business_id
147
+ try:
148
+ return cited_ctx.client.post(endpoints.AUDIT_START, json=payload)
149
+ except CitedAPIError as e:
150
+ return _api_error_response(e)
151
+
152
+
153
+ @mcp.tool()
154
+ async def get_audit_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
155
+ """Check the status of a running audit job."""
156
+ cited_ctx = _get_ctx(ctx)
157
+ if err := _auth_check(cited_ctx):
158
+ return err
159
+ try:
160
+ return cited_ctx.client.get(endpoints.AUDIT_STATUS.format(job_id=job_id))
161
+ except CitedAPIError as e:
162
+ return _api_error_response(e)
163
+
164
+
165
+ @mcp.tool()
166
+ async def get_audit_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
167
+ """Get the results of a completed audit job."""
168
+ cited_ctx = _get_ctx(ctx)
169
+ if err := _auth_check(cited_ctx):
170
+ return err
171
+ try:
172
+ return cited_ctx.client.get(endpoints.AUDIT_RESULT.format(job_id=job_id))
173
+ except CitedAPIError as e:
174
+ return _api_error_response(e)
175
+
176
+
177
+ @mcp.tool()
178
+ async def list_audits(ctx: Context[Any, CitedContext, Any]) -> Any:
179
+ """List audit history."""
180
+ cited_ctx = _get_ctx(ctx)
181
+ if err := _auth_check(cited_ctx):
182
+ return err
183
+ try:
184
+ return cited_ctx.client.get(endpoints.AUDIT_HISTORY)
185
+ except CitedAPIError as e:
186
+ return _api_error_response(e)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import webbrowser
4
+ from typing import Any
5
+
6
+ import anyio
7
+ from mcp.server.fastmcp import Context
8
+
9
+ from cited_core.api import endpoints
10
+ from cited_core.auth.oauth_server import OAuthCallbackServer
11
+ from cited_core.auth.store import TokenStore
12
+ from cited_core.config.constants import FRONTEND_URLS
13
+ from cited_core.errors import CitedAPIError
14
+ from cited_mcp.context import CitedContext
15
+ from cited_mcp.server import mcp
16
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
17
+
18
+
19
+ @mcp.tool()
20
+ async def check_auth_status(ctx: Context[Any, CitedContext, Any]) -> Any:
21
+ """Check if the user is authenticated and return their account info."""
22
+ cited_ctx = _get_ctx(ctx)
23
+ if err := _auth_check(cited_ctx):
24
+ return err
25
+ try:
26
+ return cited_ctx.client.get(endpoints.ME)
27
+ except CitedAPIError as e:
28
+ return _api_error_response(e)
29
+
30
+
31
+ @mcp.tool()
32
+ async def login(ctx: Context[Any, CitedContext, Any], env: str | None = None) -> Any:
33
+ """Log in to Cited by opening a browser window for OAuth authentication.
34
+
35
+ Args:
36
+ ctx: MCP context
37
+ env: Optional environment override (dev, prod, local)
38
+ """
39
+ cited_ctx = _get_ctx(ctx)
40
+ target_env = env or cited_ctx.env
41
+
42
+ callback_server = OAuthCallbackServer(timeout=120.0)
43
+ callback_server.start()
44
+
45
+ frontend = FRONTEND_URLS.get(target_env, FRONTEND_URLS["prod"])
46
+ login_url = f"{frontend}/login?callback={callback_server.redirect_uri}"
47
+
48
+ webbrowser.open(login_url)
49
+
50
+ token = await anyio.to_thread.run_sync(callback_server.wait_for_token)
51
+ callback_server.shutdown()
52
+
53
+ if not token:
54
+ return {"error": True, "message": "Login timed out. Please try again."}
55
+
56
+ TokenStore().save_token(target_env, token)
57
+ cited_ctx.client.token = token
58
+
59
+ return {"success": True, "message": f"Logged in successfully (env: {target_env})"}
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.api import endpoints
8
+ from cited_core.errors import CitedAPIError
9
+ from cited_mcp.context import CitedContext
10
+ from cited_mcp.server import mcp
11
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
12
+
13
+
14
+ @mcp.tool()
15
+ async def list_businesses(ctx: Context[Any, CitedContext, Any]) -> Any:
16
+ """List all businesses for the authenticated user."""
17
+ cited_ctx = _get_ctx(ctx)
18
+ if err := _auth_check(cited_ctx):
19
+ return err
20
+ try:
21
+ return cited_ctx.client.get(endpoints.BUSINESSES)
22
+ except CitedAPIError as e:
23
+ return _api_error_response(e)
24
+
25
+
26
+ @mcp.tool()
27
+ async def get_business(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
28
+ """Get details for a specific business by ID."""
29
+ cited_ctx = _get_ctx(ctx)
30
+ if err := _auth_check(cited_ctx):
31
+ return err
32
+ try:
33
+ return cited_ctx.client.get(endpoints.BUSINESS.format(business_id=business_id))
34
+ except CitedAPIError as e:
35
+ return _api_error_response(e)
36
+
37
+
38
+ @mcp.tool()
39
+ async def create_business(
40
+ ctx: Context[Any, CitedContext, Any],
41
+ name: str,
42
+ website: str,
43
+ description: str,
44
+ industry: str = "technology",
45
+ ) -> Any:
46
+ """Create a new business.
47
+
48
+ Args:
49
+ ctx: MCP context
50
+ name: Business name
51
+ website: Business website (must be a publicly DNS-resolvable domain)
52
+ description: Business description (minimum ~50 characters)
53
+ industry: One of: automotive, beauty, consulting, education, entertainment,
54
+ finance, fitness, government, healthcare, home_services, hospitality,
55
+ legal, manufacturing, non_profit, real_estate, restaurant, retail,
56
+ technology, other
57
+ """
58
+ cited_ctx = _get_ctx(ctx)
59
+ if err := _auth_check(cited_ctx):
60
+ return err
61
+ try:
62
+ return cited_ctx.client.post(
63
+ endpoints.BUSINESSES,
64
+ json={
65
+ "name": name,
66
+ "website": website,
67
+ "description": description,
68
+ "industry": industry,
69
+ },
70
+ )
71
+ except CitedAPIError as e:
72
+ return _api_error_response(e)
73
+
74
+
75
+ @mcp.tool()
76
+ async def update_business(
77
+ ctx: Context[Any, CitedContext, Any],
78
+ business_id: str,
79
+ name: str | None = None,
80
+ website: str | None = None,
81
+ description: str | None = None,
82
+ industry: str | None = None,
83
+ ) -> Any:
84
+ """Update an existing business. Only provided fields are changed.
85
+
86
+ Args:
87
+ ctx: MCP context
88
+ business_id: Business ID to update
89
+ name: New business name
90
+ website: New website URL
91
+ description: New description
92
+ industry: New industry value
93
+ """
94
+ cited_ctx = _get_ctx(ctx)
95
+ if err := _auth_check(cited_ctx):
96
+ return err
97
+ payload: dict[str, Any] = {}
98
+ if name is not None:
99
+ payload["name"] = name
100
+ if website is not None:
101
+ payload["website"] = website
102
+ if description is not None:
103
+ payload["description"] = description
104
+ if industry is not None:
105
+ payload["industry"] = industry
106
+ try:
107
+ return cited_ctx.client.put(
108
+ endpoints.BUSINESS.format(business_id=business_id), json=payload
109
+ )
110
+ except CitedAPIError as e:
111
+ return _api_error_response(e)
112
+
113
+
114
+ @mcp.tool()
115
+ async def delete_business(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
116
+ """Delete a business and all its associated data.
117
+
118
+ Args:
119
+ ctx: MCP context
120
+ business_id: Business ID to delete
121
+ """
122
+ cited_ctx = _get_ctx(ctx)
123
+ if err := _auth_check(cited_ctx):
124
+ return err
125
+ try:
126
+ cited_ctx.client.delete(endpoints.BUSINESS.format(business_id=business_id))
127
+ return {"success": True, "message": f"Business {business_id} deleted"}
128
+ except CitedAPIError as e:
129
+ return _api_error_response(e)
130
+
131
+
132
+ @mcp.tool()
133
+ async def crawl_business(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
134
+ """Start a crawl job for a business. Returns a job_id you can poll with get_job_status."""
135
+ cited_ctx = _get_ctx(ctx)
136
+ if err := _auth_check(cited_ctx):
137
+ return err
138
+ try:
139
+ return cited_ctx.client.post(endpoints.CRAWL_START.format(business_id=business_id))
140
+ except CitedAPIError as e:
141
+ return _api_error_response(e)
142
+
143
+
144
+ @mcp.tool()
145
+ async def get_health_scores(ctx: Context[Any, CitedContext, Any], business_id: str) -> Any:
146
+ """Get GEO health scores for a business."""
147
+ cited_ctx = _get_ctx(ctx)
148
+ if err := _auth_check(cited_ctx):
149
+ return err
150
+ try:
151
+ return cited_ctx.client.get(endpoints.HEALTH_SCORES.format(business_id=business_id))
152
+ except CitedAPIError as e:
153
+ return _api_error_response(e)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.api import endpoints
8
+ from cited_core.errors import CitedAPIError
9
+ from cited_mcp.context import CitedContext
10
+ from cited_mcp.server import mcp
11
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
12
+
13
+ _STATUS_ENDPOINTS = {
14
+ "audit": endpoints.AUDIT_STATUS,
15
+ "recommendation": endpoints.RECOMMEND_STATUS,
16
+ "solution": endpoints.SOLUTION_STATUS,
17
+ }
18
+
19
+
20
+ @mcp.tool()
21
+ async def get_job_status(
22
+ ctx: Context[Any, CitedContext, Any],
23
+ job_id: str,
24
+ job_type: str | None = None,
25
+ ) -> Any:
26
+ """Get the status of any job by ID.
27
+
28
+ Args:
29
+ ctx: MCP context
30
+ job_id: The job ID to check
31
+ job_type: Optional job type hint: audit, recommendation, or solution.
32
+ If not provided, will probe each type until one responds.
33
+ """
34
+ cited_ctx = _get_ctx(ctx)
35
+ if err := _auth_check(cited_ctx):
36
+ return err
37
+
38
+ if job_type and job_type in _STATUS_ENDPOINTS:
39
+ try:
40
+ result = cited_ctx.client.get(
41
+ _STATUS_ENDPOINTS[job_type].format(job_id=job_id)
42
+ )
43
+ result["job_type"] = job_type
44
+ return result
45
+ except CitedAPIError as e:
46
+ return _api_error_response(e)
47
+
48
+ # Probe each type
49
+ for jtype, endpoint_tpl in _STATUS_ENDPOINTS.items():
50
+ try:
51
+ result = cited_ctx.client.get(endpoint_tpl.format(job_id=job_id))
52
+ result["job_type"] = jtype
53
+ return result
54
+ except CitedAPIError:
55
+ continue
56
+
57
+ return {"error": True, "message": f"No job found with ID: {job_id}"}
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.api import endpoints
8
+ from cited_core.errors import CitedAPIError
9
+ from cited_mcp.context import CitedContext
10
+ from cited_mcp.server import mcp
11
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
12
+
13
+
14
+ @mcp.tool()
15
+ async def start_recommendation(ctx: Context[Any, CitedContext, Any], audit_job_id: str) -> Any:
16
+ """Start a recommendation job based on a completed audit.
17
+
18
+ Args:
19
+ ctx: MCP context
20
+ audit_job_id: The completed audit job ID to generate recommendations for
21
+ """
22
+ cited_ctx = _get_ctx(ctx)
23
+ if err := _auth_check(cited_ctx):
24
+ return err
25
+ try:
26
+ return cited_ctx.client.post(
27
+ endpoints.RECOMMEND_START,
28
+ json={"audit_job_id": audit_job_id},
29
+ )
30
+ except CitedAPIError as e:
31
+ return _api_error_response(e)
32
+
33
+
34
+ @mcp.tool()
35
+ async def get_recommendation_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
36
+ """Check the status of a recommendation job."""
37
+ cited_ctx = _get_ctx(ctx)
38
+ if err := _auth_check(cited_ctx):
39
+ return err
40
+ try:
41
+ return cited_ctx.client.get(endpoints.RECOMMEND_STATUS.format(job_id=job_id))
42
+ except CitedAPIError as e:
43
+ return _api_error_response(e)
44
+
45
+
46
+ @mcp.tool()
47
+ async def get_recommendation_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
48
+ """Get the full results of a completed recommendation job."""
49
+ cited_ctx = _get_ctx(ctx)
50
+ if err := _auth_check(cited_ctx):
51
+ return err
52
+ try:
53
+ return cited_ctx.client.get(endpoints.RECOMMEND_RESULT.format(job_id=job_id))
54
+ except CitedAPIError as e:
55
+ return _api_error_response(e)
56
+
57
+
58
+ @mcp.tool()
59
+ async def get_recommendation_insights(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
60
+ """Get actionable insights from a recommendation, with source_type and source_id for solutions.
61
+
62
+ Returns question_insights, head_to_head_comparisons, strengthening_tips, and
63
+ priority_actions, each annotated with the source_type and source_id needed to
64
+ start a solution via start_solution.
65
+ """
66
+ cited_ctx = _get_ctx(ctx)
67
+ if err := _auth_check(cited_ctx):
68
+ return err
69
+ try:
70
+ result = cited_ctx.client.get(endpoints.RECOMMEND_RESULT.format(job_id=job_id))
71
+ except CitedAPIError as e:
72
+ return _api_error_response(e)
73
+
74
+ insights: dict[str, Any] = {}
75
+
76
+ for item in result.get("question_insights", []):
77
+ item["source_type"] = "question_insight"
78
+ item["source_id"] = item.get("question_id", "")
79
+ insights["question_insights"] = result.get("question_insights", [])
80
+
81
+ for item in result.get("head_to_head_comparisons", []):
82
+ item["source_type"] = "head_to_head"
83
+ item["source_id"] = item.get("competitor_domain", "")
84
+ insights["head_to_head_comparisons"] = result.get("head_to_head_comparisons", [])
85
+
86
+ for item in result.get("strengthening_tips", []):
87
+ item["source_type"] = "strengthening_tip"
88
+ item["source_id"] = item.get("category", "")
89
+ insights["strengthening_tips"] = result.get("strengthening_tips", [])
90
+
91
+ for item in result.get("priority_actions", []):
92
+ item["source_type"] = "priority_action"
93
+ item["source_id"] = item.get("id", item.get("category", ""))
94
+ insights["priority_actions"] = result.get("priority_actions", [])
95
+
96
+ return insights
97
+
98
+
99
+ @mcp.tool()
100
+ async def list_recommendations(
101
+ ctx: Context[Any, CitedContext, Any], audit_job_id: str
102
+ ) -> Any:
103
+ """List recommendation history for a specific audit.
104
+
105
+ Args:
106
+ ctx: MCP context
107
+ audit_job_id: The audit job ID to list recommendations for
108
+ """
109
+ cited_ctx = _get_ctx(ctx)
110
+ if err := _auth_check(cited_ctx):
111
+ return err
112
+ try:
113
+ return cited_ctx.client.get(
114
+ endpoints.RECOMMEND_HISTORY.format(audit_job_id=audit_job_id)
115
+ )
116
+ except CitedAPIError as e:
117
+ return _api_error_response(e)
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from cited_core.api import endpoints
8
+ from cited_core.errors import CitedAPIError
9
+ from cited_mcp.context import CitedContext
10
+ from cited_mcp.server import mcp
11
+ from cited_mcp.tools._helpers import _api_error_response, _auth_check, _get_ctx
12
+
13
+
14
+ @mcp.tool()
15
+ async def start_solution(
16
+ ctx: Context[Any, CitedContext, Any],
17
+ recommendation_job_id: str,
18
+ source_type: str,
19
+ source_id: str,
20
+ ) -> Any:
21
+ """Start generating a solution for a specific recommendation insight.
22
+
23
+ Args:
24
+ ctx: MCP context
25
+ recommendation_job_id: The recommendation job ID
26
+ source_type: One of: question_insight, head_to_head, strengthening_tip, priority_action
27
+ source_id: The source identifier (question_id, competitor_domain, category, etc.)
28
+ """
29
+ cited_ctx = _get_ctx(ctx)
30
+ if err := _auth_check(cited_ctx):
31
+ return err
32
+ try:
33
+ return cited_ctx.client.post(
34
+ endpoints.SOLUTION_REQUEST,
35
+ json={
36
+ "recommendation_job_id": recommendation_job_id,
37
+ "source_type": source_type,
38
+ "source_id": source_id,
39
+ },
40
+ )
41
+ except CitedAPIError as e:
42
+ return _api_error_response(e)
43
+
44
+
45
+ @mcp.tool()
46
+ async def get_solution_status(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
47
+ """Check the status of a solution generation job."""
48
+ cited_ctx = _get_ctx(ctx)
49
+ if err := _auth_check(cited_ctx):
50
+ return err
51
+ try:
52
+ return cited_ctx.client.get(endpoints.SOLUTION_STATUS.format(job_id=job_id))
53
+ except CitedAPIError as e:
54
+ return _api_error_response(e)
55
+
56
+
57
+ @mcp.tool()
58
+ async def get_solution_result(ctx: Context[Any, CitedContext, Any], job_id: str) -> Any:
59
+ """Get the results of a completed solution generation job."""
60
+ cited_ctx = _get_ctx(ctx)
61
+ if err := _auth_check(cited_ctx):
62
+ return err
63
+ try:
64
+ return cited_ctx.client.get(endpoints.SOLUTION_RESULT.format(job_id=job_id))
65
+ except CitedAPIError as e:
66
+ return _api_error_response(e)
67
+
68
+
69
+ @mcp.tool()
70
+ async def list_solutions(ctx: Context[Any, CitedContext, Any]) -> Any:
71
+ """List solution history."""
72
+ cited_ctx = _get_ctx(ctx)
73
+ if err := _auth_check(cited_ctx):
74
+ return err
75
+ try:
76
+ return cited_ctx.client.get(endpoints.SOLUTION_HISTORY)
77
+ except CitedAPIError as e:
78
+ return _api_error_response(e)