mcp-methods 0.1.1__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.
Binary file
@@ -0,0 +1,126 @@
1
+ name: Build and Publish Python Package
2
+
3
+ on:
4
+ push:
5
+ paths:
6
+ - 'pyproject.toml'
7
+ - 'mcp_methods/**'
8
+ workflow_dispatch: # Allow manual triggering
9
+
10
+ jobs:
11
+ version:
12
+ name: Extract version
13
+ runs-on: ubuntu-latest
14
+ outputs:
15
+ version: ${{ steps.get_version.outputs.version }}
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Extract version from pyproject.toml
21
+ id: get_version
22
+ run: |
23
+ VERSION=$(grep -m 1 "version" pyproject.toml | cut -d '"' -f 2)
24
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
25
+ echo "Package version: $VERSION"
26
+
27
+ test:
28
+ name: Test on Python ${{ matrix.python-version }}
29
+ runs-on: ubuntu-latest
30
+ strategy:
31
+ fail-fast: false
32
+ matrix:
33
+ python-version: ['3.10', '3.11', '3.12', '3.13']
34
+ steps:
35
+ - name: Checkout code
36
+ uses: actions/checkout@v4
37
+
38
+ - name: Set up Python ${{ matrix.python-version }}
39
+ uses: actions/setup-python@v5
40
+ with:
41
+ python-version: ${{ matrix.python-version }}
42
+
43
+ - name: Install dependencies
44
+ run: |
45
+ python -m pip install --upgrade pip
46
+ pip install -e ".[dev]"
47
+
48
+ - name: Run tests
49
+ run: python -m pytest tests/ -v
50
+
51
+ - name: Test import
52
+ run: |
53
+ python -c "from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache; print('Import successful')"
54
+
55
+ build:
56
+ name: Build distribution
57
+ needs: [version, test]
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - name: Checkout code
61
+ uses: actions/checkout@v4
62
+
63
+ - name: Set up Python
64
+ uses: actions/setup-python@v5
65
+ with:
66
+ python-version: '3.12'
67
+
68
+ - name: Install build dependencies
69
+ run: |
70
+ python -m pip install --upgrade pip
71
+ pip install build twine
72
+
73
+ - name: Build package
74
+ run: python -m build
75
+
76
+ - name: Check distribution
77
+ run: twine check dist/*
78
+
79
+ - name: List distribution files
80
+ run: ls -lh dist/
81
+
82
+ - name: Upload distribution as artifact
83
+ uses: actions/upload-artifact@v4
84
+ with:
85
+ name: python-package-distributions
86
+ path: dist/
87
+
88
+ publish:
89
+ name: Publish to PyPI
90
+ needs: [version, build]
91
+ runs-on: ubuntu-latest
92
+ permissions:
93
+ contents: write
94
+ id-token: write # Required for trusted publishing
95
+ steps:
96
+ - name: Download distributions
97
+ uses: actions/download-artifact@v4
98
+ with:
99
+ name: python-package-distributions
100
+ path: dist/
101
+
102
+ - name: Display structure of downloaded files
103
+ run: ls -lh dist/
104
+
105
+ - name: Publish to PyPI
106
+ uses: pypa/gh-action-pypi-publish@release/v1
107
+ with:
108
+ password: ${{ secrets.PYPI_API_TOKEN }}
109
+ skip-existing: true
110
+ # Uncomment to test on TestPyPI first
111
+ # repository-url: https://test.pypi.org/legacy/
112
+
113
+ - name: Checkout code for release
114
+ uses: actions/checkout@v4
115
+
116
+ - name: Create GitHub Release
117
+ uses: softprops/action-gh-release@v2
118
+ with:
119
+ files: dist/*
120
+ generate_release_notes: true
121
+ tag_name: v${{ needs.version.outputs.version }}
122
+ name: Release v${{ needs.version.outputs.version }}
123
+ draft: false
124
+ prerelease: false
125
+ env:
126
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kristian Kollsgard
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,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-methods
3
+ Version: 0.1.1
4
+ Summary: Reusable utility methods for MCP servers
5
+ Author: Kristian Kollsgard
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agent,ai,github,mcp,tools
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: requests>=2.28.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.0; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
23
+ Requires-Dist: pytest>=7.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # mcp-methods
28
+
29
+ Reusable utility methods for MCP servers. Extracts common patterns from MCP server implementations into a shared, pip-installable library.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache
41
+
42
+ # GitHub API
43
+ result = git_api("pydata/xarray", "pulls?state=open")
44
+
45
+ # Fetch issue/PR with smart compaction
46
+ cache = ElementCache()
47
+ result = git_issue("pydata/xarray", 11124, cache=cache)
48
+
49
+ # Search files
50
+ result = grep_files(["/path/to/source"], "pattern", glob="*.py")
51
+
52
+ # Read file with path traversal protection
53
+ result = read_file("src/main.py", ["/path/to/source"])
54
+ ```
@@ -0,0 +1,28 @@
1
+ # mcp-methods
2
+
3
+ Reusable utility methods for MCP servers. Extracts common patterns from MCP server implementations into a shared, pip-installable library.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from mcp_methods import git_issue, git_api, grep_files, read_file, ElementCache
15
+
16
+ # GitHub API
17
+ result = git_api("pydata/xarray", "pulls?state=open")
18
+
19
+ # Fetch issue/PR with smart compaction
20
+ cache = ElementCache()
21
+ result = git_issue("pydata/xarray", 11124, cache=cache)
22
+
23
+ # Search files
24
+ result = grep_files(["/path/to/source"], "pattern", glob="*.py")
25
+
26
+ # Read file with path traversal protection
27
+ result = read_file("src/main.py", ["/path/to/source"])
28
+ ```
@@ -0,0 +1,27 @@
1
+ """mcp-methods: Reusable utility methods for MCP servers."""
2
+
3
+ from mcp_methods._utils import load_env, timed
4
+ from mcp_methods.files import grep_files, read_file
5
+ from mcp_methods.git import (
6
+ ElementCache,
7
+ detect_git_repo,
8
+ extract_github_refs,
9
+ git_api,
10
+ git_issue,
11
+ has_git_token,
12
+ validate_repo,
13
+ )
14
+
15
+ __all__ = [
16
+ "timed",
17
+ "load_env",
18
+ "git_api",
19
+ "git_issue",
20
+ "ElementCache",
21
+ "detect_git_repo",
22
+ "validate_repo",
23
+ "has_git_token",
24
+ "extract_github_refs",
25
+ "grep_files",
26
+ "read_file",
27
+ ]
@@ -0,0 +1,50 @@
1
+ """Shared utilities for MCP method libraries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+
12
+ def timed(func: Callable) -> Callable:
13
+ """Decorator that appends timing info to string return values.
14
+
15
+ Usage::
16
+
17
+ @timed
18
+ def my_tool(...) -> str:
19
+ ...
20
+ """
21
+
22
+ @functools.wraps(func)
23
+ def wrapper(*args, **kwargs):
24
+ t0 = time.perf_counter()
25
+ result = func(*args, **kwargs)
26
+ ms = (time.perf_counter() - t0) * 1000
27
+ return result + f"\n\n⏱ {ms:.0f}ms"
28
+
29
+ return wrapper
30
+
31
+
32
+ def load_env(env_file: str | Path) -> None:
33
+ """Load key=value pairs from a .env file into ``os.environ``.
34
+
35
+ - Blank lines and lines starting with ``#`` are skipped.
36
+ - Values may be optionally quoted with single or double quotes.
37
+ - Existing environment variables are **not** overwritten.
38
+ """
39
+ path = Path(env_file)
40
+ if not path.exists():
41
+ return
42
+ for line in path.read_text().splitlines():
43
+ line = line.strip()
44
+ if not line or line.startswith("#") or "=" not in line:
45
+ continue
46
+ key, _, val = line.partition("=")
47
+ key = key.strip()
48
+ val = val.strip().strip("'\"")
49
+ if key and key not in os.environ:
50
+ os.environ[key] = val
@@ -0,0 +1,178 @@
1
+ """File search and reading methods for MCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Constants
11
+ # ---------------------------------------------------------------------------
12
+
13
+ DEFAULT_SKIP_DIRS: set[str] = {
14
+ ".git",
15
+ "node_modules",
16
+ "__pycache__",
17
+ ".tox",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ "dist",
21
+ "build",
22
+ ".eggs",
23
+ "venv",
24
+ ".venv",
25
+ "target",
26
+ ".cargo",
27
+ ".ruff_cache",
28
+ }
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Public API
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def grep_files(
36
+ source_dirs: list[str | Path],
37
+ pattern: str,
38
+ *,
39
+ glob: str = "*",
40
+ case_insensitive: bool = False,
41
+ max_results: int = 50,
42
+ skip_dirs: set[str] | None = None,
43
+ relative_to: str | Path | None = None,
44
+ transform: Callable[[str], str] | None = None,
45
+ ) -> str:
46
+ """Search for a text pattern across files in *source_dirs*.
47
+
48
+ *source_dirs*: directories to search (recursively).
49
+ *pattern*: plain text or regex pattern to search for.
50
+ *glob*: file glob to filter (default ``"*"`` for all files).
51
+ *case_insensitive*: set True for case-insensitive matching.
52
+ *max_results*: maximum number of matching lines to return.
53
+ *skip_dirs*: directory names to skip (defaults to :data:`DEFAULT_SKIP_DIRS`).
54
+ *relative_to*: if set, display paths relative to this directory.
55
+ *transform*: optional function to transform file content before searching
56
+ (e.g. HTML-to-text converter).
57
+ """
58
+ try:
59
+ flags = re.IGNORECASE if case_insensitive else 0
60
+ regex = re.compile(pattern, flags)
61
+ except re.error as e:
62
+ return f"Invalid regex pattern: {e}"
63
+
64
+ if skip_dirs is None:
65
+ skip_dirs = DEFAULT_SKIP_DIRS
66
+
67
+ rel_base = Path(relative_to) if relative_to else None
68
+
69
+ matches: list[str] = []
70
+ for source_dir in source_dirs:
71
+ source_dir = Path(source_dir)
72
+ if not source_dir.is_dir():
73
+ continue
74
+ for path in sorted(source_dir.rglob(glob)):
75
+ if not path.is_file():
76
+ continue
77
+ if any(part in skip_dirs for part in path.parts):
78
+ continue
79
+ try:
80
+ if rel_base:
81
+ rel = str(path.relative_to(rel_base))
82
+ else:
83
+ rel = str(path.relative_to(source_dir))
84
+ text = path.read_text(encoding="utf-8")
85
+ if transform:
86
+ text = transform(text)
87
+ for i, line in enumerate(text.splitlines(), 1):
88
+ if regex.search(line):
89
+ matches.append(f" {rel}:{i} {line.rstrip()}")
90
+ if len(matches) >= max_results:
91
+ break
92
+ except (UnicodeDecodeError, PermissionError):
93
+ continue
94
+ if len(matches) >= max_results:
95
+ break
96
+ if len(matches) >= max_results:
97
+ break
98
+
99
+ if not matches:
100
+ return f"No matches for '{pattern}' in {glob} files."
101
+ header = f"Found {len(matches)} match(es) for '{pattern}'"
102
+ if len(matches) >= max_results:
103
+ header += f" (capped at {max_results})"
104
+ return header + ":\n" + "\n".join(matches)
105
+
106
+
107
+ def read_file(
108
+ file_path: str,
109
+ allowed_dirs: list[str | Path],
110
+ *,
111
+ start_line: int | None = None,
112
+ end_line: int | None = None,
113
+ max_chars: int | None = None,
114
+ transform: Callable[[str], str] | None = None,
115
+ ) -> str:
116
+ """Read a file with path-traversal protection.
117
+
118
+ *file_path*: relative path resolved against each directory in *allowed_dirs*.
119
+ *allowed_dirs*: directories in which the file must reside (path traversal guard).
120
+ *start_line*: first line to include (1-indexed).
121
+ *end_line*: last line to include (1-indexed, inclusive).
122
+ *max_chars*: truncate the result to this many characters.
123
+ *transform*: optional function to transform file content before returning
124
+ (e.g. HTML-to-text converter).
125
+ """
126
+ # Resolve file against allowed directories
127
+ resolved: Path | None = None
128
+ base_dir: Path | None = None
129
+ for d in allowed_dirs:
130
+ d = Path(d)
131
+ candidate = (d / file_path).resolve()
132
+ if candidate.is_relative_to(d.resolve()) and candidate.exists():
133
+ resolved = candidate
134
+ base_dir = d
135
+ break
136
+
137
+ if resolved is None:
138
+ # Check if it's an absolute path inside an allowed dir
139
+ abs_path = Path(file_path).resolve()
140
+ for d in allowed_dirs:
141
+ d = Path(d)
142
+ if abs_path.is_relative_to(d.resolve()) and abs_path.exists():
143
+ resolved = abs_path
144
+ base_dir = d
145
+ break
146
+
147
+ if resolved is None:
148
+ return f"Error: file not found or access denied: {file_path}"
149
+
150
+ try:
151
+ raw = resolved.read_text(encoding="utf-8")
152
+ except Exception as e:
153
+ return f"Error reading file: {e}"
154
+
155
+ # Apply transform (e.g. HTML to text)
156
+ if transform:
157
+ raw = transform(raw)
158
+
159
+ all_lines = raw.splitlines()
160
+ total = len(all_lines)
161
+
162
+ if start_line is not None or end_line is not None:
163
+ s = max(1, start_line or 1)
164
+ e = min(total, end_line or total)
165
+ selected = all_lines[s - 1 : e]
166
+ numbered = [f"{i:>5} {line}" for i, line in enumerate(selected, start=s)]
167
+ header = f"{file_path}:{s}-{e} ({e - s + 1} of {total} lines)"
168
+ else:
169
+ selected = all_lines
170
+ numbered = [f"{i:>5} {line}" for i, line in enumerate(selected, start=1)]
171
+ header = f"{file_path} ({total} lines)"
172
+
173
+ text = header + "\n" + "\n".join(numbered)
174
+
175
+ if max_chars and len(text) > max_chars:
176
+ text = text[:max_chars] + f"\n\n[... truncated at {max_chars} chars — {len(raw)} total]"
177
+
178
+ return text