prioris-mcp 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PriorisMCP project contributors.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: prioris-mcp
3
+ Version: 0.1.0
4
+ Summary: A model context protocol (MCP) server to facilitate looking up prior art.
5
+ Keywords: python3,prior-art,research,literature-review,model-context-protocol-server,mcp-server
6
+ Author: Anirban Basu
7
+ Author-email: Anirban Basu <anirbanbasu@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: anyio>=4.14.2
17
+ Requires-Dist: defusedxml>=0.7.1
18
+ Requires-Dist: environs>=15.0.1
19
+ Requires-Dist: fastmcp>=3.4.4
20
+ Requires-Dist: httpx>=0.28.1
21
+ Requires-Dist: liteparse>=2.9.0
22
+ Requires-Dist: lxml>=6.1.1
23
+ Requires-Dist: markdownify>=1.2.3
24
+ Requires-Python: >=3.13
25
+ Project-URL: Repository, https://github.com/anirbanbasu/prioris-mcp
26
+ Project-URL: Issues, https://github.com/anirbanbasu/prioris-mcp/issues
27
+ Project-URL: Download, https://pypi.org/project/prioris-mcp/
28
+ Description-Content-Type: text/markdown
29
+
30
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue?logo=python&logoColor=3776ab&labelColor=e4e4e4)](https://www.python.org/downloads/release/python-3130/) [![Documentation](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml) [![pytest](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml) [![PyPI](https://img.shields.io/pypi/v/prioris-mcp?label=pypi%20package)](https://pypi.org/project/prioris-mcp/#history) ![GitHub commits since latest release](https://img.shields.io/github/commits-since/anirbanbasu/prioris-mcp/latest) [![CodeQL Advanced](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/anirbanbasu/prioris-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/anirbanbasu/prioris-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
+
32
+ # Prioris MCP
33
+
34
+ A model context protocol (MCP) server to facilitate looking up prior art.
35
+
36
+ Full documentation, including installation, configuration, and how to connect this server to Claude, is published at **[the documentation site](https://docs-prioris-mcp.anirbanbasu.com)**.
37
+
38
+ # Contributing
39
+
40
+ See the [Contributing guide](CONTRIBUTING.md).
41
+
42
+ # License
43
+
44
+ [MIT](https://choosealicense.com/licenses/mit/).
@@ -0,0 +1,15 @@
1
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue?logo=python&logoColor=3776ab&labelColor=e4e4e4)](https://www.python.org/downloads/release/python-3130/) [![Documentation](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml) [![pytest](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml) [![PyPI](https://img.shields.io/pypi/v/prioris-mcp?label=pypi%20package)](https://pypi.org/project/prioris-mcp/#history) ![GitHub commits since latest release](https://img.shields.io/github/commits-since/anirbanbasu/prioris-mcp/latest) [![CodeQL Advanced](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml/badge.svg)](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/anirbanbasu/prioris-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/anirbanbasu/prioris-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
2
+
3
+ # Prioris MCP
4
+
5
+ A model context protocol (MCP) server to facilitate looking up prior art.
6
+
7
+ Full documentation, including installation, configuration, and how to connect this server to Claude, is published at **[the documentation site](https://docs-prioris-mcp.anirbanbasu.com)**.
8
+
9
+ # Contributing
10
+
11
+ See the [Contributing guide](CONTRIBUTING.md).
12
+
13
+ # License
14
+
15
+ [MIT](https://choosealicense.com/licenses/mit/).
@@ -0,0 +1,137 @@
1
+ [project]
2
+ name = "prioris-mcp"
3
+ version = "0.1.0"
4
+ description = "A model context protocol (MCP) server to facilitate looking up prior art."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICEN[CS]E*"]
8
+ authors = [
9
+ { name = "Anirban Basu", email = "anirbanbasu@users.noreply.github.com" }
10
+ ]
11
+ keywords = ["python3", "prior-art", "research", "literature-review", "model-context-protocol-server", "mcp-server"]
12
+ requires-python = ">=3.13"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: Education",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.13",
20
+ ]
21
+ dependencies = [
22
+ "anyio>=4.14.2",
23
+ "defusedxml>=0.7.1",
24
+ "environs>=15.0.1",
25
+ "fastmcp>=3.4.4",
26
+ "httpx>=0.28.1",
27
+ "liteparse>=2.9.0",
28
+ "lxml>=6.1.1",
29
+ "markdownify>=1.2.3",
30
+ ]
31
+
32
+ [project.scripts]
33
+ prioris-mcp = "prioris_mcp.server:main"
34
+
35
+ [project.urls]
36
+ Repository = "https://github.com/anirbanbasu/prioris-mcp"
37
+ Issues = "https://github.com/anirbanbasu/prioris-mcp/issues"
38
+ Download = "https://pypi.org/project/prioris-mcp/"
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.11.29,<0.12.0"]
42
+ build-backend = "uv_build"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "icecream>=2.1.8",
47
+ "ty>=0.0.1a29",
48
+ ]
49
+ docs = [
50
+ "zensical>=0.0.51",
51
+ ]
52
+ test = [
53
+ "coverage>=7.9.1",
54
+ "pytest>=8.4.1",
55
+ ]
56
+
57
+ [tool.ty.rules]
58
+ possibly-unresolved-reference = "warn"
59
+ # Keyword argument expansion is not supported by ty yet? https://github.com/astral-sh/ty/issues/247
60
+ invalid-argument-type = "ignore"
61
+
62
+ [tool.ty.environment]
63
+ python-version = "3.13"
64
+
65
+ [tool.ruff]
66
+ line-length = 120
67
+ target-version = "py313"
68
+ include = [
69
+ "src/prioris_mcp/**/*.py",
70
+ "tests/**/*.py",
71
+ "docs/**/*.py",
72
+ ]
73
+
74
+ [tool.ruff.lint]
75
+ extend-select = [
76
+ "Q",
77
+ "RUF100",
78
+ "RUF018", # https://docs.astral.sh/ruff/rules/assignment-in-assert/
79
+ "C90",
80
+ "UP",
81
+ "I",
82
+ "D",
83
+ "TID251",
84
+ ]
85
+ # flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" }
86
+ mccabe = { max-complexity = 15 }
87
+ ignore = [
88
+ "D100", # ignore missing docstring in module
89
+ "D102", # ignore missing docstring in public method
90
+ "D104", # ignore missing docstring in public package
91
+ "D105", # ignore missing docstring in magic methods
92
+ "D107", # ignore missing docstring in __init__ methods
93
+ ]
94
+
95
+ [tool.ruff.lint.isort]
96
+ combine-as-imports = true
97
+ known-first-party = ["prioris-mcp"]
98
+
99
+ [tool.ruff.lint.pydocstyle]
100
+ convention = "google"
101
+
102
+ [tool.ruff.format]
103
+ # don't format python in docstrings, pytest-examples takes care of it
104
+ docstring-code-format = false
105
+ # quote-style = "single"
106
+
107
+ [tool.coverage.run]
108
+ omit = [
109
+ "tests/*", # Excludes all files and subdirectories within a 'tests' directory at the project root
110
+ ]
111
+
112
+ [tool.coverage.report]
113
+ fail_under = 100
114
+ skip_covered = true
115
+ show_missing = true
116
+ ignore_errors = true
117
+ precision = 2
118
+ exclude_lines = [
119
+ # `# pragma: no cover` is standard marker for code that's not covered, this will error if code is covered
120
+ 'pragma: no cover',
121
+ # use `# pragma: lax no cover` if you want to ignore cases where (some of) the code is covered
122
+ 'pragma: lax no cover',
123
+ 'raise NotImplementedError',
124
+ 'if TYPE_CHECKING:',
125
+ 'if typing.TYPE_CHECKING:',
126
+ '@overload',
127
+ '@deprecated',
128
+ '@typing.overload',
129
+ '@abstractmethod',
130
+ '\(Protocol\):$',
131
+ 'typing.assert_never',
132
+ '$\s*assert_never\(',
133
+ 'if __name__ == .__main__.:',
134
+ 'except ImportError as _import_error:',
135
+ '$\s*pass$',
136
+ 'assert False',
137
+ ]
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from environs import Env
6
+ from marshmallow.validate import OneOf, Range
7
+ from rich.logging import RichHandler
8
+
9
+ PACKAGE_NAME = "prioris-mcp"
10
+ env = Env()
11
+ env.read_env()
12
+
13
+
14
+ class EnvVars:
15
+ """Environment variables for Prioris MCP configuration."""
16
+
17
+ PRIORIS_MCP_ASGI_CORS_ALLOWED_ORIGINS = env.list(
18
+ "PRIORIS_MCP_ASGI_CORS_ALLOWED_ORIGINS",
19
+ # Default is restricted to localhost, not a wildcard: a wildcard origin lets any webpage's
20
+ # JavaScript read this server's responses, not just tools actually served from localhost.
21
+ # Override explicitly (e.g. to "*") for tools that need it, such as the MCP Inspector.
22
+ default=["http://localhost", "http://127.0.0.1"],
23
+ )
24
+
25
+ PRIORIS_MCP_HOST = env.str("PRIORIS_MCP_HOST", default="localhost")
26
+ PRIORIS_MCP_PORT = env.int(
27
+ "PRIORIS_MCP_PORT", default=8000, validate=Range(min=1024, max=49151)
28
+ ) # Valid port range is 1024-49151 for non-privileged ports
29
+
30
+ PRIORIS_MCP_LOG_LEVEL = env.str(
31
+ "PRIORIS_MCP_LOG_LEVEL",
32
+ default="INFO",
33
+ validate=OneOf(["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
34
+ ).upper()
35
+
36
+ PRIORIS_MCP_TRANSPORT: str = env.str(
37
+ name="PRIORIS_MCP_TRANSPORT",
38
+ default="stdio",
39
+ validate=OneOf(["stdio", "streamable-http", "http"]),
40
+ )
41
+
42
+ PRIORIS_MCP_RESPONSE_CACHE_TTL: int = env.int(
43
+ name="PRIORIS_MCP_RESPONSE_CACHE_TTL",
44
+ default=30, # in seconds
45
+ validate=Range(min=0, max=86400), # 0 seconds to 1 day where 0 means caching is disabled
46
+ )
47
+
48
+ PRIORIS_MCP_UNVERIFIED_HTTPS: bool = env.bool(
49
+ name="PRIORIS_MCP_UNVERIFIED_HTTPS",
50
+ default=False, # By default, HTTPS requests are verified; override in development for testing
51
+ )
52
+
53
+ _default_data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
54
+
55
+ PRIORIS_MCP_STORAGE_DIR = env.path(
56
+ "PRIORIS_MCP_STORAGE_DIR",
57
+ # Data, not configuration, hence XDG_DATA_HOME (not XDG_CONFIG_HOME) - see
58
+ # docs/requirement-specification/02-storage.md.
59
+ default=_default_data_home / "prioris-mcp" / "downloads",
60
+ )
61
+
62
+ PRIORIS_MCP_RATE_LIMIT_BACKOFF_BUDGET_SECONDS = env.float(
63
+ "PRIORIS_MCP_RATE_LIMIT_BACKOFF_BUDGET_SECONDS",
64
+ # Total time a single tool call's rate-limit backoff may spend retrying before giving up
65
+ # with `rate_limited` - see docs/requirement-specification/04-non-functional-requirements.md.
66
+ default=60.0,
67
+ validate=Range(min=0, max=3600),
68
+ )
69
+
70
+ # Clamped to the host's CPU count even if the environment variable requests more: this bounds
71
+ # concurrently-*executing* JATS transforms (not just concurrently-awaited ones) against
72
+ # unbounded resource use from abandoned, still-running transforms - see
73
+ # docs/requirement-specification/05-security.md#a-bounded-per-call-failure-is-not-sufficient-on-its-own.
74
+ PRIORIS_MCP_JATS_MAX_CONCURRENT_TRANSFORMS = min(
75
+ env.int(
76
+ "PRIORIS_MCP_JATS_MAX_CONCURRENT_TRANSFORMS",
77
+ default=min(4, os.cpu_count() or 4),
78
+ validate=Range(min=1),
79
+ ),
80
+ os.cpu_count() or 4,
81
+ )
82
+
83
+
84
+ logging.basicConfig(
85
+ level=EnvVars.PRIORIS_MCP_LOG_LEVEL,
86
+ format="%(message)s",
87
+ datefmt="[%X]",
88
+ handlers=[RichHandler(rich_tracebacks=False, markup=True, show_path=False, show_time=False)],
89
+ )
@@ -0,0 +1,75 @@
1
+ """Shared error types and the tool-level `{"error": ..., "message": ...}` envelope.
2
+
3
+ See docs/requirement-specification/06-interface-specification.md#conventions for the envelope
4
+ shape and the codes below - every `research_*` tool method returns this envelope on failure
5
+ instead of letting an exception cross the MCP protocol boundary as a raw error.
6
+ """
7
+
8
+ from collections.abc import Awaitable
9
+ from typing import Any
10
+
11
+ from prioris_mcp.rate_limit import ProviderUnavailableError, RateLimitedError
12
+
13
+
14
+ class NotFoundError(Exception):
15
+ """Identifier not recognised by the provider, or requested format not yet persisted.
16
+
17
+ Maps to the `not_found` error code.
18
+ """
19
+
20
+
21
+ class FormatUnavailableError(Exception):
22
+ """Identifier is valid but does not offer the requested format.
23
+
24
+ Maps to the `format_unavailable` error code.
25
+ """
26
+
27
+
28
+ class UnsupportedProviderError(Exception):
29
+ """A DOI resolved to a domain outside the v1 provider allowlist.
30
+
31
+ Maps to the `unsupported_provider` error code.
32
+ """
33
+
34
+
35
+ class InvalidRequestError(Exception):
36
+ """Caller-supplied arguments fail a provider's own validation before any outbound call.
37
+
38
+ Maps to the `invalid_request` error code.
39
+ """
40
+
41
+
42
+ _ERROR_CODES: dict[type[Exception], str] = {
43
+ NotFoundError: "not_found",
44
+ FormatUnavailableError: "format_unavailable",
45
+ UnsupportedProviderError: "unsupported_provider",
46
+ InvalidRequestError: "invalid_request",
47
+ RateLimitedError: "rate_limited",
48
+ ProviderUnavailableError: "provider_unavailable",
49
+ }
50
+
51
+
52
+ def to_error_envelope(exc: Exception) -> dict[str, str]:
53
+ """Convert one of the mapped exception types into the tool-level error envelope.
54
+
55
+ Raises:
56
+ TypeError: `exc` isn't one of the mapped types - an unmapped exception is a bug in the
57
+ calling tool method (it should not have been caught), not a case to paper over with
58
+ a generic error code.
59
+ """
60
+ for exc_type, code in _ERROR_CODES.items():
61
+ if isinstance(exc, exc_type):
62
+ return {"error": code, "message": str(exc)}
63
+ raise TypeError(f"No error code mapped for exception type {type(exc).__name__}")
64
+
65
+
66
+ async def call_returning_envelope(coro: Awaitable[dict[str, Any]]) -> dict[str, Any]:
67
+ """Await `coro`, converting any mapped exception into the error envelope instead of raising.
68
+
69
+ Every `research_*` tool method on `PriorisMCP` wraps its provider call with this, so the
70
+ try/except-to-envelope translation is written once, not once per tool.
71
+ """
72
+ try:
73
+ return await coro
74
+ except tuple(_ERROR_CODES.keys()) as exc:
75
+ return to_error_envelope(exc)
@@ -0,0 +1,82 @@
1
+ import logging
2
+ import time
3
+ from importlib.metadata import PackageMetadata, metadata as importlib_metadata
4
+ from typing import ClassVar
5
+
6
+ from fastmcp.server.middleware import Middleware
7
+
8
+ from prioris_mcp import PACKAGE_NAME
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class StripUnknownArgumentsMiddleware(Middleware):
14
+ """Middleware to strip unknown arguments from MCP feature invocations."""
15
+
16
+ async def on_call_tool(self, context, call_next):
17
+ """Filter out unknown arguments from tool calls."""
18
+ try:
19
+ # Only proceed if this is a tool call with non-zero arguments
20
+ if context.fastmcp_context and context.message.arguments and len(context.message.arguments) > 0:
21
+ tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
22
+ tool_args = tool.parameters.get("properties", None)
23
+ expected_args_names = set(tool_args.keys()) if tool_args else set()
24
+ filtered_args = {k: v for k, v in context.message.arguments.items() if k in expected_args_names}
25
+ unknown_args = set(context.message.arguments.keys()).difference(expected_args_names)
26
+ if unknown_args:
27
+ logger.info(f"Unknown arguments for tool '{context.message.name}': {list(unknown_args)}")
28
+ context.message.arguments = filtered_args # modify in place
29
+ except Exception: # pragma: no cover
30
+ logger.exception(
31
+ f"Error in {StripUnknownArgumentsMiddleware.__name__}.on_call_tool",
32
+ stack_info=True,
33
+ )
34
+ return await call_next(context)
35
+
36
+
37
+ class ResponseMetadataMiddleware(Middleware):
38
+ """Middleware to add metadata to MCP responses."""
39
+
40
+ _package_metadata: ClassVar[PackageMetadata] = importlib_metadata(PACKAGE_NAME)
41
+ PACKAGE_METADATA_KEY: ClassVar[str] = "_package_metadata"
42
+ TIMING_METADATA_KEY: ClassVar[str] = "_timing_metadata"
43
+
44
+ async def _time_operation(self, context, call_next, operation_name: str):
45
+ """Helper method to time any operation."""
46
+ # Based on: https://github.com/jlowin/fastmcp/blob/ee6340526216c7796000c69ef3b9001a1a6f31a3/src/fastmcp/server/middleware/timing.py#L91C5-L109C18
47
+ start_time = time.perf_counter()
48
+ try:
49
+ result = await call_next(context)
50
+ duration_ms = (time.perf_counter() - start_time) * 1000
51
+ logger.debug(f"{operation_name} completed in {duration_ms:.2f}ms")
52
+ return result, duration_ms
53
+ except Exception as e:
54
+ duration_ms = (time.perf_counter() - start_time) * 1000
55
+ logger.warning(
56
+ f"{operation_name} failed after {duration_ms:.2f}ms: {e}",
57
+ )
58
+ raise
59
+
60
+ async def on_call_tool(self, context, call_next):
61
+ """Add metadata to tool responses."""
62
+ # There is no support for top-level metadata for prompts or resources yet.
63
+ # Do not encapsulate this in a try-except; let errors propagate
64
+ feature_name = getattr(context.message, "name", "unknown")
65
+ result, duration_ms = await self._time_operation(context, call_next, f"Tool '{feature_name}'")
66
+
67
+ if result is None: # pragma: no cover
68
+ # Isn't this an impossible scenario?
69
+ return result
70
+ if getattr(result, "meta", None) is None:
71
+ result.meta = {} # pragma: no cover
72
+ result.meta[ResponseMetadataMiddleware.PACKAGE_METADATA_KEY] = {
73
+ "name": ResponseMetadataMiddleware._package_metadata["name"],
74
+ "version": ResponseMetadataMiddleware._package_metadata["version"],
75
+ }
76
+ result.meta[ResponseMetadataMiddleware.TIMING_METADATA_KEY] = {
77
+ "tool_response_time_ms": duration_ms,
78
+ }
79
+ logger.debug(
80
+ f"Added package metadata to tool response: {result.meta[ResponseMetadataMiddleware.PACKAGE_METADATA_KEY]}"
81
+ )
82
+ return result
@@ -0,0 +1,71 @@
1
+ import copy
2
+ import logging
3
+ from typing import Any, ClassVar
4
+
5
+ from fastmcp import FastMCP
6
+ from fastmcp.tools import ToolResult
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class MCPMixin:
12
+ """A mixin class to register tools, resources, and prompts with a FastMCP instance."""
13
+
14
+ # Each entry is a dict, must include "fn" (method name),
15
+ # rest is arbitrary metadata relevant to FastMCP.
16
+ tools: ClassVar[list[dict[str, Any]]] = []
17
+ # Each entry is a dict, must include "fn" (method name) and "uri",
18
+ # rest is arbitrary metadata relevant to FastMCP.
19
+ resources: ClassVar[list[dict[str, Any]]] = []
20
+ # Each entry is a dict, must include "fn" (method name),
21
+ # rest is arbitrary metadata relevant to FastMCP.
22
+ prompts: ClassVar[list[dict[str, Any]]] = []
23
+
24
+ def register_features(self, mcp: FastMCP) -> FastMCP:
25
+ """Register tools, resources, and prompts with the given FastMCP instance.
26
+
27
+ Args:
28
+ mcp (FastMCP): The FastMCP instance to register features with.
29
+
30
+ Returns:
31
+ FastMCP: The FastMCP instance with registered features.
32
+ """
33
+ # Register tools
34
+ for tool in self.tools:
35
+ assert "fn" in tool, "Tool metadata must include the 'fn' key."
36
+ tool_copy = copy.deepcopy(tool)
37
+ fn_name = tool_copy.pop("fn")
38
+ fn = getattr(self, fn_name)
39
+ mcp.tool(**tool_copy)(fn) # pass remaining metadata as kwargs
40
+ # Register resources
41
+ for res in self.resources: # pragma: no cover
42
+ assert "fn" in res and "uri" in res, "Resource metadata must include 'fn' and 'uri' keys."
43
+ res_copy = copy.deepcopy(res)
44
+ fn_name = res_copy.pop("fn")
45
+ uri = res_copy.pop("uri")
46
+ fn = getattr(self, fn_name)
47
+ mcp.resource(uri, **res_copy)(fn)
48
+ # Register prompts
49
+ for pr in self.prompts: # pragma: no cover
50
+ assert "fn" in pr, "Prompt metadata must include the 'fn' key."
51
+ pr_copy = copy.deepcopy(pr)
52
+ fn_name = pr_copy.pop("fn")
53
+ fn = getattr(self, fn_name)
54
+ mcp.prompt(**pr_copy)(fn)
55
+
56
+ return mcp
57
+
58
+ def get_tool_result(self, result: Any, metadata: dict[str, Any] | None = None) -> ToolResult: # pragma: no cover
59
+ """Create a ToolResult object with the given result and metadata, including package metadata.
60
+
61
+ Args:
62
+ result (Any): The result to include in the ToolResult.
63
+ metadata (Dict[str, Any] | None, optional): Additional metadata to include. Defaults to None.
64
+
65
+ Returns:
66
+ ToolResult: The ToolResult object containing the result and metadata.
67
+ """
68
+ return ToolResult(
69
+ structured_content={"result": result} if not isinstance(result, dict) else result,
70
+ meta=metadata if metadata is not None else None,
71
+ )
File without changes
@@ -0,0 +1,30 @@
1
+ """Pluggable parser-backend interface: converts persisted full text into Markdown.
2
+
3
+ See docs/requirement-specification/01-architecture.md#parse_full_text - one backend per source
4
+ format, swappable by configuration without changing this interface, mirroring
5
+ `StorageBackend`'s interface-plus-swappable-implementation shape.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+
10
+
11
+ class ParseError(Exception):
12
+ """A source document could not be converted to Markdown.
13
+
14
+ Covers both malformed input and a backend-enforced resource bound being exceeded - see
15
+ docs/requirement-specification/05-security.md#fetched-content-is-untrusted-input-to-parse_full_text.
16
+ Callers treat both the same way: a bounded, typed failure, not a crash or a hang.
17
+ """
18
+
19
+
20
+ class ParserBackend(ABC):
21
+ """Converts one source format's raw bytes into a Markdown string."""
22
+
23
+ @abstractmethod
24
+ async def to_markdown(self, content: bytes) -> str:
25
+ """Convert `content` to Markdown.
26
+
27
+ Raises:
28
+ ParseError: the content is malformed, or parsing exceeded this backend's own
29
+ resource bound (size/time).
30
+ """