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.
- prioris_mcp-0.1.0/LICENSE +21 -0
- prioris_mcp-0.1.0/PKG-INFO +44 -0
- prioris_mcp-0.1.0/README.md +15 -0
- prioris_mcp-0.1.0/pyproject.toml +137 -0
- prioris_mcp-0.1.0/src/prioris_mcp/__init__.py +89 -0
- prioris_mcp-0.1.0/src/prioris_mcp/errors.py +75 -0
- prioris_mcp-0.1.0/src/prioris_mcp/middleware.py +82 -0
- prioris_mcp-0.1.0/src/prioris_mcp/mixin.py +71 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/__init__.py +0 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/base.py +30 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/data/jats-html.xsl +4024 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/html_markdownify.py +46 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/jats_xslt.py +113 -0
- prioris_mcp-0.1.0/src/prioris_mcp/parsers/pdf_liteparse.py +46 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/__init__.py +0 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/arxiv.py +265 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/base.py +54 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/europepmc.py +206 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/http.py +28 -0
- prioris_mcp-0.1.0/src/prioris_mcp/providers/identifier_routing.py +126 -0
- prioris_mcp-0.1.0/src/prioris_mcp/rate_limit.py +77 -0
- prioris_mcp-0.1.0/src/prioris_mcp/server.py +329 -0
- prioris_mcp-0.1.0/src/prioris_mcp/storage.py +180 -0
|
@@ -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
|
+
[](https://www.python.org/downloads/release/python-3130/) [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml) [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml) [](https://pypi.org/project/prioris-mcp/#history)  [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml) [](https://scorecard.dev/viewer/?uri=github.com/anirbanbasu/prioris-mcp) [](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
|
+
[](https://www.python.org/downloads/release/python-3130/) [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/docs.yml) [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/uv-pytest-coverage.yml) [](https://pypi.org/project/prioris-mcp/#history)  [](https://github.com/anirbanbasu/prioris-mcp/actions/workflows/codeql.yml) [](https://scorecard.dev/viewer/?uri=github.com/anirbanbasu/prioris-mcp) [](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
|
+
"""
|