crossref-local 0.3.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
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.
- crossref_local/__init__.py +38 -16
- crossref_local/__main__.py +0 -0
- crossref_local/_aio/__init__.py +30 -0
- crossref_local/_aio/_impl.py +238 -0
- crossref_local/_cache/__init__.py +15 -0
- crossref_local/_cache/export.py +100 -0
- crossref_local/_cache/utils.py +93 -0
- crossref_local/_cache/viz.py +296 -0
- crossref_local/_cli/__init__.py +9 -0
- crossref_local/_cli/cache.py +179 -0
- crossref_local/_cli/cli.py +512 -0
- crossref_local/_cli/completion.py +245 -0
- crossref_local/_cli/main.py +20 -0
- crossref_local/_cli/mcp.py +351 -0
- crossref_local/_cli/mcp_server.py +413 -0
- crossref_local/_core/__init__.py +58 -0
- crossref_local/{api.py → _core/api.py} +130 -36
- crossref_local/{citations.py → _core/citations.py} +55 -26
- crossref_local/{config.py → _core/config.py} +57 -42
- crossref_local/{db.py → _core/db.py} +32 -26
- crossref_local/{fts.py → _core/fts.py} +18 -14
- crossref_local/{models.py → _core/models.py} +11 -6
- crossref_local/{impact_factor → _impact_factor}/__init__.py +0 -0
- crossref_local/{impact_factor → _impact_factor}/calculator.py +0 -0
- crossref_local/{impact_factor → _impact_factor}/journal_lookup.py +0 -0
- crossref_local/_remote/__init__.py +56 -0
- crossref_local/_remote/base.py +356 -0
- crossref_local/_remote/collections.py +175 -0
- crossref_local/_server/__init__.py +140 -0
- crossref_local/_server/middleware.py +25 -0
- crossref_local/_server/models.py +129 -0
- crossref_local/_server/routes_citations.py +98 -0
- crossref_local/_server/routes_collections.py +282 -0
- crossref_local/_server/routes_compat.py +102 -0
- crossref_local/_server/routes_works.py +128 -0
- crossref_local/_server/server.py +19 -0
- crossref_local/aio.py +30 -206
- crossref_local/cache.py +466 -0
- crossref_local/cli.py +5 -447
- crossref_local/jobs.py +169 -0
- crossref_local/mcp_server.py +5 -199
- crossref_local/remote.py +5 -261
- crossref_local/server.py +5 -349
- {crossref_local-0.3.1.dist-info → crossref_local-0.5.0.dist-info}/METADATA +88 -24
- crossref_local-0.5.0.dist-info/RECORD +47 -0
- crossref_local-0.3.1.dist-info/RECORD +0 -20
- {crossref_local-0.3.1.dist-info → crossref_local-0.5.0.dist-info}/WHEEL +0 -0
- {crossref_local-0.3.1.dist-info → crossref_local-0.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Shell completion commands for crossref-local CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
PROG_NAME = "crossref-local"
|
|
10
|
+
|
|
11
|
+
# Shell completion scripts
|
|
12
|
+
BASH_COMPLETION = f"""# {PROG_NAME} bash completion
|
|
13
|
+
eval "$(_CROSSREF_LOCAL_COMPLETE=bash_source {PROG_NAME})"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
ZSH_COMPLETION = f"""# {PROG_NAME} zsh completion
|
|
17
|
+
eval "$(_CROSSREF_LOCAL_COMPLETE=zsh_source {PROG_NAME})"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
FISH_COMPLETION = f"""# {PROG_NAME} fish completion
|
|
21
|
+
_CROSSREF_LOCAL_COMPLETE=fish_source {PROG_NAME} | source
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Shell config files
|
|
25
|
+
SHELL_CONFIGS = {
|
|
26
|
+
"bash": [Path.home() / ".bashrc", Path.home() / ".bash_profile"],
|
|
27
|
+
"zsh": [Path.home() / ".zshrc"],
|
|
28
|
+
"fish": [Path.home() / ".config" / "fish" / "config.fish"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
COMPLETION_SCRIPTS = {
|
|
32
|
+
"bash": BASH_COMPLETION,
|
|
33
|
+
"zsh": ZSH_COMPLETION,
|
|
34
|
+
"fish": FISH_COMPLETION,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
COMPLETION_MARKER = f"# >>> {PROG_NAME} completion >>>"
|
|
38
|
+
COMPLETION_END_MARKER = f"# <<< {PROG_NAME} completion <<<"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detect_shell() -> str:
|
|
42
|
+
"""Detect current shell from $SHELL environment variable."""
|
|
43
|
+
shell_path = os.environ.get("SHELL", "")
|
|
44
|
+
shell_name = Path(shell_path).name if shell_path else ""
|
|
45
|
+
|
|
46
|
+
if shell_name in ("bash", "zsh", "fish"):
|
|
47
|
+
return shell_name
|
|
48
|
+
|
|
49
|
+
# Fallback to bash
|
|
50
|
+
return "bash"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_config_file(shell: str) -> Path | None:
|
|
54
|
+
"""Get the appropriate config file for the shell."""
|
|
55
|
+
configs = SHELL_CONFIGS.get(shell, [])
|
|
56
|
+
for config in configs:
|
|
57
|
+
if config.exists():
|
|
58
|
+
return config
|
|
59
|
+
# Return first option for creation
|
|
60
|
+
return configs[0] if configs else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_installed(shell: str) -> tuple[bool, Path | None]:
|
|
64
|
+
"""Check if completion is already installed for a shell."""
|
|
65
|
+
configs = SHELL_CONFIGS.get(shell, [])
|
|
66
|
+
for config in configs:
|
|
67
|
+
if config.exists():
|
|
68
|
+
content = config.read_text()
|
|
69
|
+
if COMPLETION_MARKER in content:
|
|
70
|
+
return True, config
|
|
71
|
+
return False, None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _install_completion(shell: str) -> tuple[bool, str]:
|
|
75
|
+
"""Install completion for a shell. Returns (success, message)."""
|
|
76
|
+
installed, existing_file = _is_installed(shell)
|
|
77
|
+
if installed:
|
|
78
|
+
return True, f"Already installed in {existing_file}"
|
|
79
|
+
|
|
80
|
+
config_file = _get_config_file(shell)
|
|
81
|
+
if config_file is None:
|
|
82
|
+
return False, f"Could not find config file for {shell}"
|
|
83
|
+
|
|
84
|
+
script = COMPLETION_SCRIPTS.get(shell)
|
|
85
|
+
if script is None:
|
|
86
|
+
return False, f"Unsupported shell: {shell}"
|
|
87
|
+
|
|
88
|
+
# Create parent directory if needed (for fish)
|
|
89
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# Append completion to config file
|
|
92
|
+
completion_block = f"\n{COMPLETION_MARKER}\n{script}{COMPLETION_END_MARKER}\n"
|
|
93
|
+
|
|
94
|
+
with open(config_file, "a") as f:
|
|
95
|
+
f.write(completion_block)
|
|
96
|
+
|
|
97
|
+
return True, f"Installed to {config_file}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _uninstall_completion(shell: str) -> tuple[bool, str]:
|
|
101
|
+
"""Uninstall completion for a shell. Returns (success, message)."""
|
|
102
|
+
installed, config_file = _is_installed(shell)
|
|
103
|
+
if not installed:
|
|
104
|
+
return True, f"Not installed for {shell}"
|
|
105
|
+
|
|
106
|
+
if config_file is None:
|
|
107
|
+
return False, f"Could not find config file for {shell}"
|
|
108
|
+
|
|
109
|
+
content = config_file.read_text()
|
|
110
|
+
|
|
111
|
+
# Remove the completion block
|
|
112
|
+
start_idx = content.find(COMPLETION_MARKER)
|
|
113
|
+
end_idx = content.find(COMPLETION_END_MARKER)
|
|
114
|
+
|
|
115
|
+
if start_idx == -1 or end_idx == -1:
|
|
116
|
+
return False, "Could not find completion block to remove"
|
|
117
|
+
|
|
118
|
+
# Include the newline before marker and after end marker
|
|
119
|
+
if start_idx > 0 and content[start_idx - 1] == "\n":
|
|
120
|
+
start_idx -= 1
|
|
121
|
+
end_idx = end_idx + len(COMPLETION_END_MARKER)
|
|
122
|
+
if end_idx < len(content) and content[end_idx] == "\n":
|
|
123
|
+
end_idx += 1
|
|
124
|
+
|
|
125
|
+
new_content = content[:start_idx] + content[end_idx:]
|
|
126
|
+
config_file.write_text(new_content)
|
|
127
|
+
|
|
128
|
+
return True, f"Removed from {config_file}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@click.group("completion", invoke_without_command=True)
|
|
132
|
+
@click.pass_context
|
|
133
|
+
def completion(ctx):
|
|
134
|
+
"""Shell completion commands.
|
|
135
|
+
|
|
136
|
+
\b
|
|
137
|
+
Install shell tab-completion for crossref-local CLI.
|
|
138
|
+
Running without subcommand auto-installs for detected shell.
|
|
139
|
+
|
|
140
|
+
\b
|
|
141
|
+
Examples:
|
|
142
|
+
crossref-local completion # Auto-install for current shell
|
|
143
|
+
crossref-local completion status # Check installation status
|
|
144
|
+
crossref-local completion bash # Show bash completion script
|
|
145
|
+
"""
|
|
146
|
+
if ctx.invoked_subcommand is None:
|
|
147
|
+
# Auto-install for detected shell
|
|
148
|
+
shell = _detect_shell()
|
|
149
|
+
click.echo(f"Detected shell: {shell}")
|
|
150
|
+
|
|
151
|
+
success, message = _install_completion(shell)
|
|
152
|
+
if success:
|
|
153
|
+
click.echo(f"[OK] {message}")
|
|
154
|
+
click.echo(
|
|
155
|
+
f"\nRestart your shell or run: source ~/{_get_config_file(shell).name}"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
click.echo(f"[ERROR] {message}", err=True)
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@completion.command("install")
|
|
163
|
+
@click.option(
|
|
164
|
+
"--shell",
|
|
165
|
+
type=click.Choice(["bash", "zsh", "fish"]),
|
|
166
|
+
default=None,
|
|
167
|
+
help="Shell to install completion for (default: auto-detect)",
|
|
168
|
+
)
|
|
169
|
+
def install_cmd(shell: str | None):
|
|
170
|
+
"""Install completion to shell config file."""
|
|
171
|
+
if shell is None:
|
|
172
|
+
shell = _detect_shell()
|
|
173
|
+
click.echo(f"Detected shell: {shell}")
|
|
174
|
+
|
|
175
|
+
success, message = _install_completion(shell)
|
|
176
|
+
if success:
|
|
177
|
+
click.echo(f"[OK] {message}")
|
|
178
|
+
config_file = _get_config_file(shell)
|
|
179
|
+
if config_file:
|
|
180
|
+
click.echo(f"\nRestart your shell or run: source {config_file}")
|
|
181
|
+
else:
|
|
182
|
+
click.echo(f"[ERROR] {message}", err=True)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@completion.command("uninstall")
|
|
187
|
+
@click.option(
|
|
188
|
+
"--shell",
|
|
189
|
+
type=click.Choice(["bash", "zsh", "fish"]),
|
|
190
|
+
default=None,
|
|
191
|
+
help="Shell to uninstall completion from (default: auto-detect)",
|
|
192
|
+
)
|
|
193
|
+
def uninstall_cmd(shell: str | None):
|
|
194
|
+
"""Remove completion from shell config file."""
|
|
195
|
+
if shell is None:
|
|
196
|
+
shell = _detect_shell()
|
|
197
|
+
click.echo(f"Detected shell: {shell}")
|
|
198
|
+
|
|
199
|
+
success, message = _uninstall_completion(shell)
|
|
200
|
+
if success:
|
|
201
|
+
click.echo(f"[OK] {message}")
|
|
202
|
+
else:
|
|
203
|
+
click.echo(f"[ERROR] {message}", err=True)
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@completion.command("status")
|
|
208
|
+
def status_cmd():
|
|
209
|
+
"""Check completion installation status for all shells."""
|
|
210
|
+
click.echo(f"{PROG_NAME} Shell Completion Status")
|
|
211
|
+
click.echo("=" * 40)
|
|
212
|
+
|
|
213
|
+
current_shell = _detect_shell()
|
|
214
|
+
click.echo(f"Current shell: {current_shell}")
|
|
215
|
+
click.echo()
|
|
216
|
+
|
|
217
|
+
for shell in ["bash", "zsh", "fish"]:
|
|
218
|
+
installed, config_file = _is_installed(shell)
|
|
219
|
+
marker = "[x]" if installed else "[ ]"
|
|
220
|
+
location = f" ({config_file})" if installed and config_file else ""
|
|
221
|
+
current = " (current)" if shell == current_shell else ""
|
|
222
|
+
click.echo(f" {marker} {shell}{current}{location}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@completion.command("bash")
|
|
226
|
+
def bash_cmd():
|
|
227
|
+
"""Show bash completion script."""
|
|
228
|
+
click.echo(BASH_COMPLETION.strip())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@completion.command("zsh")
|
|
232
|
+
def zsh_cmd():
|
|
233
|
+
"""Show zsh completion script."""
|
|
234
|
+
click.echo(ZSH_COMPLETION.strip())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@completion.command("fish")
|
|
238
|
+
def fish_cmd():
|
|
239
|
+
"""Show fish completion script."""
|
|
240
|
+
click.echo(FISH_COMPLETION.strip())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def register_completion_commands(cli_group):
|
|
244
|
+
"""Register completion commands with the main CLI group."""
|
|
245
|
+
cli_group.add_command(completion)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Main CLI entry point with all command groups registered."""
|
|
2
|
+
|
|
3
|
+
from .cli import cli
|
|
4
|
+
from .cli_cache import register_cache_commands
|
|
5
|
+
from .cli_completion import register_completion_commands
|
|
6
|
+
from .cli_mcp import register_mcp_commands
|
|
7
|
+
|
|
8
|
+
# Register command groups
|
|
9
|
+
register_cache_commands(cli)
|
|
10
|
+
register_completion_commands(cli)
|
|
11
|
+
register_mcp_commands(cli)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
"""Entry point for CLI."""
|
|
16
|
+
cli()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""MCP CLI subcommands for crossref_local."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .. import info
|
|
7
|
+
|
|
8
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AliasedGroup(click.Group):
|
|
12
|
+
"""Click group that supports command aliases."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
super().__init__(*args, **kwargs)
|
|
16
|
+
self._aliases = {}
|
|
17
|
+
|
|
18
|
+
def command(self, *args, aliases=None, **kwargs):
|
|
19
|
+
def decorator(f):
|
|
20
|
+
cmd = super(AliasedGroup, self).command(*args, **kwargs)(f)
|
|
21
|
+
if aliases:
|
|
22
|
+
for alias in aliases:
|
|
23
|
+
self._aliases[alias] = cmd.name
|
|
24
|
+
return cmd
|
|
25
|
+
|
|
26
|
+
return decorator
|
|
27
|
+
|
|
28
|
+
def get_command(self, ctx, cmd_name):
|
|
29
|
+
cmd_name = self._aliases.get(cmd_name, cmd_name)
|
|
30
|
+
return super().get_command(ctx, cmd_name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
|
|
34
|
+
def mcp():
|
|
35
|
+
"""MCP (Model Context Protocol) server commands.
|
|
36
|
+
|
|
37
|
+
\b
|
|
38
|
+
Commands:
|
|
39
|
+
start - Start the MCP server
|
|
40
|
+
doctor - Diagnose MCP setup
|
|
41
|
+
installation - Show installation instructions
|
|
42
|
+
list-tools - List available MCP tools
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.command("start", context_settings=CONTEXT_SETTINGS)
|
|
48
|
+
@click.option(
|
|
49
|
+
"-t",
|
|
50
|
+
"--transport",
|
|
51
|
+
type=click.Choice(["stdio", "sse", "http"]),
|
|
52
|
+
default="stdio",
|
|
53
|
+
help="Transport protocol (http recommended for remote)",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--host",
|
|
57
|
+
default="localhost",
|
|
58
|
+
envvar="CROSSREF_LOCAL_MCP_HOST",
|
|
59
|
+
help="Host for HTTP/SSE transport",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--port",
|
|
63
|
+
default=8082,
|
|
64
|
+
type=int,
|
|
65
|
+
envvar="CROSSREF_LOCAL_MCP_PORT",
|
|
66
|
+
help="Port for HTTP/SSE transport",
|
|
67
|
+
)
|
|
68
|
+
def mcp_start(transport: str, host: str, port: int):
|
|
69
|
+
"""Start the MCP server.
|
|
70
|
+
|
|
71
|
+
\b
|
|
72
|
+
Transports:
|
|
73
|
+
stdio - Standard I/O (default, for Claude Desktop local)
|
|
74
|
+
http - Streamable HTTP (recommended for remote/persistent)
|
|
75
|
+
sse - Server-Sent Events (deprecated as of MCP spec 2025-03-26)
|
|
76
|
+
|
|
77
|
+
\b
|
|
78
|
+
Local configuration (stdio):
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"crossref": {
|
|
82
|
+
"command": "crossref-local",
|
|
83
|
+
"args": ["mcp", "start"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
\b
|
|
89
|
+
Remote configuration (http):
|
|
90
|
+
# Start server:
|
|
91
|
+
crossref-local mcp start -t http --host 0.0.0.0 --port 8082
|
|
92
|
+
|
|
93
|
+
# Client config:
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"crossref-remote": {
|
|
97
|
+
"url": "http://your-server:8082/mcp"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
\b
|
|
103
|
+
See docs/remote-deployment.md for systemd and Docker setup.
|
|
104
|
+
"""
|
|
105
|
+
run_mcp_server(transport, host, port)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.command("doctor", context_settings=CONTEXT_SETTINGS)
|
|
109
|
+
def mcp_doctor():
|
|
110
|
+
"""Diagnose MCP server setup and dependencies."""
|
|
111
|
+
click.echo("MCP Server Diagnostics")
|
|
112
|
+
click.echo("=" * 50)
|
|
113
|
+
click.echo()
|
|
114
|
+
|
|
115
|
+
# Check fastmcp
|
|
116
|
+
click.echo("Dependencies:")
|
|
117
|
+
try:
|
|
118
|
+
import fastmcp
|
|
119
|
+
|
|
120
|
+
click.echo(
|
|
121
|
+
f" [OK] fastmcp installed (v{getattr(fastmcp, '__version__', 'unknown')})"
|
|
122
|
+
)
|
|
123
|
+
except ImportError:
|
|
124
|
+
click.echo(" [FAIL] fastmcp not installed")
|
|
125
|
+
click.echo(" Fix: pip install crossref-local[mcp]")
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
click.echo()
|
|
129
|
+
|
|
130
|
+
# Check database
|
|
131
|
+
click.echo("Database:")
|
|
132
|
+
try:
|
|
133
|
+
db_info = info()
|
|
134
|
+
click.echo(" [OK] Database accessible")
|
|
135
|
+
click.echo(f" Works: {db_info.get('works', 0):,}")
|
|
136
|
+
click.echo(f" FTS indexed: {db_info.get('fts_indexed', 0):,}")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
click.echo(f" [FAIL] Database error: {e}")
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
|
|
141
|
+
click.echo()
|
|
142
|
+
click.echo("All checks passed! MCP server is ready.")
|
|
143
|
+
click.echo()
|
|
144
|
+
click.echo("Start with:")
|
|
145
|
+
click.echo(" crossref-local mcp start # stdio (Claude Desktop)")
|
|
146
|
+
click.echo(" crossref-local mcp start -t http # HTTP transport")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@mcp.command("installation", context_settings=CONTEXT_SETTINGS)
|
|
150
|
+
def mcp_installation():
|
|
151
|
+
"""Show MCP client installation instructions."""
|
|
152
|
+
click.echo("MCP Client Configuration")
|
|
153
|
+
click.echo("=" * 50)
|
|
154
|
+
click.echo()
|
|
155
|
+
click.echo("1. Local (stdio) - Claude Desktop / Claude Code:")
|
|
156
|
+
click.echo()
|
|
157
|
+
click.echo(" Add to your MCP client config (e.g., claude_desktop_config.json):")
|
|
158
|
+
click.echo()
|
|
159
|
+
click.echo(" {")
|
|
160
|
+
click.echo(' "mcpServers": {')
|
|
161
|
+
click.echo(' "crossref-local": {')
|
|
162
|
+
click.echo(' "command": "crossref-local",')
|
|
163
|
+
click.echo(' "args": ["mcp", "start"],')
|
|
164
|
+
click.echo(' "env": {')
|
|
165
|
+
click.echo(' "CROSSREF_LOCAL_DB": "/path/to/crossref.db"')
|
|
166
|
+
click.echo(" }")
|
|
167
|
+
click.echo(" }")
|
|
168
|
+
click.echo(" }")
|
|
169
|
+
click.echo(" }")
|
|
170
|
+
click.echo()
|
|
171
|
+
click.echo("2. Remote (HTTP) - Persistent server:")
|
|
172
|
+
click.echo()
|
|
173
|
+
click.echo(" Server side:")
|
|
174
|
+
click.echo(" crossref-local mcp start -t http --host 0.0.0.0 --port 8082")
|
|
175
|
+
click.echo()
|
|
176
|
+
click.echo(" Client config:")
|
|
177
|
+
click.echo(" {")
|
|
178
|
+
click.echo(' "mcpServers": {')
|
|
179
|
+
click.echo(' "crossref-remote": {')
|
|
180
|
+
click.echo(' "url": "http://your-server:8082/mcp"')
|
|
181
|
+
click.echo(" }")
|
|
182
|
+
click.echo(" }")
|
|
183
|
+
click.echo(" }")
|
|
184
|
+
click.echo()
|
|
185
|
+
click.echo("See docs/remote-deployment.md for systemd and Docker setup.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@mcp.command("list-tools", context_settings=CONTEXT_SETTINGS)
|
|
189
|
+
@click.option(
|
|
190
|
+
"-v", "--verbose", count=True, help="Verbosity: -v sig, -vv +desc, -vvv full"
|
|
191
|
+
)
|
|
192
|
+
@click.option("-c", "--compact", is_flag=True, help="Compact signatures (single line)")
|
|
193
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
194
|
+
def mcp_list_tools(verbose: int, compact: bool, as_json: bool):
|
|
195
|
+
"""List available MCP tools.
|
|
196
|
+
|
|
197
|
+
\b
|
|
198
|
+
Verbosity levels:
|
|
199
|
+
(none) - Tool names only
|
|
200
|
+
-v - Signatures
|
|
201
|
+
-vv - Signatures + one-line description
|
|
202
|
+
-vvv - Signatures + full description
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
from .mcp_server import mcp as mcp_server
|
|
206
|
+
except ImportError:
|
|
207
|
+
click.secho("ERROR: Could not import MCP server", fg="red", err=True)
|
|
208
|
+
click.echo("Install with: pip install crossref-local[mcp]")
|
|
209
|
+
raise SystemExit(1)
|
|
210
|
+
|
|
211
|
+
# Get tools grouped by prefix
|
|
212
|
+
tools_dict = getattr(mcp_server._tool_manager, "_tools", {})
|
|
213
|
+
modules = {}
|
|
214
|
+
for name in sorted(tools_dict.keys()):
|
|
215
|
+
prefix = name.split("_")[0]
|
|
216
|
+
if prefix not in modules:
|
|
217
|
+
modules[prefix] = []
|
|
218
|
+
modules[prefix].append(name)
|
|
219
|
+
|
|
220
|
+
if as_json:
|
|
221
|
+
import json
|
|
222
|
+
|
|
223
|
+
output = {
|
|
224
|
+
"name": "crossref-local",
|
|
225
|
+
"total": len(tools_dict),
|
|
226
|
+
"modules": {
|
|
227
|
+
mod: {
|
|
228
|
+
"count": len(tool_list),
|
|
229
|
+
"tools": [
|
|
230
|
+
{
|
|
231
|
+
"name": t,
|
|
232
|
+
"description": tools_dict[t].description
|
|
233
|
+
if tools_dict.get(t)
|
|
234
|
+
else "",
|
|
235
|
+
}
|
|
236
|
+
for t in tool_list
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
for mod, tool_list in modules.items()
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
click.echo(json.dumps(output, indent=2))
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
total = len(tools_dict)
|
|
246
|
+
click.secho("CrossRef Local MCP", fg="cyan", bold=True)
|
|
247
|
+
click.echo(f"Tools: {total} ({len(modules)} modules)")
|
|
248
|
+
click.echo()
|
|
249
|
+
|
|
250
|
+
for mod, tool_list in sorted(modules.items()):
|
|
251
|
+
click.secho(f"{mod}: {len(tool_list)} tools", fg="green", bold=True)
|
|
252
|
+
for tool_name in tool_list:
|
|
253
|
+
tool_obj = tools_dict.get(tool_name)
|
|
254
|
+
|
|
255
|
+
if verbose == 0:
|
|
256
|
+
# Names only
|
|
257
|
+
click.echo(f" {tool_name}")
|
|
258
|
+
elif verbose == 1:
|
|
259
|
+
# Signature
|
|
260
|
+
sig = (
|
|
261
|
+
_format_signature(tool_obj, multiline=not compact)
|
|
262
|
+
if tool_obj
|
|
263
|
+
else f" {tool_name}"
|
|
264
|
+
)
|
|
265
|
+
click.echo(sig)
|
|
266
|
+
elif verbose == 2:
|
|
267
|
+
# Signature + one-line description
|
|
268
|
+
sig = (
|
|
269
|
+
_format_signature(tool_obj, multiline=not compact)
|
|
270
|
+
if tool_obj
|
|
271
|
+
else f" {tool_name}"
|
|
272
|
+
)
|
|
273
|
+
click.echo(sig)
|
|
274
|
+
if tool_obj and tool_obj.description:
|
|
275
|
+
desc = tool_obj.description.split("\n")[0].strip()
|
|
276
|
+
click.echo(f" {desc}")
|
|
277
|
+
click.echo()
|
|
278
|
+
else:
|
|
279
|
+
# Signature + full description
|
|
280
|
+
sig = (
|
|
281
|
+
_format_signature(tool_obj, multiline=not compact)
|
|
282
|
+
if tool_obj
|
|
283
|
+
else f" {tool_name}"
|
|
284
|
+
)
|
|
285
|
+
click.echo(sig)
|
|
286
|
+
if tool_obj and tool_obj.description:
|
|
287
|
+
for line in tool_obj.description.strip().split("\n"):
|
|
288
|
+
click.echo(f" {line}")
|
|
289
|
+
click.echo()
|
|
290
|
+
click.echo()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _format_signature(tool_obj, multiline: bool = False, indent: str = " ") -> str:
|
|
294
|
+
"""Format tool as Python-like function signature with return type."""
|
|
295
|
+
import inspect
|
|
296
|
+
|
|
297
|
+
params = []
|
|
298
|
+
if hasattr(tool_obj, "parameters") and tool_obj.parameters:
|
|
299
|
+
schema = tool_obj.parameters
|
|
300
|
+
props = schema.get("properties", {})
|
|
301
|
+
required = schema.get("required", [])
|
|
302
|
+
for name, pinfo in props.items():
|
|
303
|
+
ptype = pinfo.get("type", "any")
|
|
304
|
+
default = pinfo.get("default")
|
|
305
|
+
if name in required:
|
|
306
|
+
p = f"{click.style(name, fg='white', bold=True)}: {click.style(ptype, fg='cyan')}"
|
|
307
|
+
elif default is not None:
|
|
308
|
+
def_str = repr(default) if len(repr(default)) < 20 else "..."
|
|
309
|
+
p = f"{click.style(name, fg='white', bold=True)}: {click.style(ptype, fg='cyan')} = {click.style(def_str, fg='yellow')}"
|
|
310
|
+
else:
|
|
311
|
+
p = f"{click.style(name, fg='white', bold=True)}: {click.style(ptype, fg='cyan')} = {click.style('None', fg='yellow')}"
|
|
312
|
+
params.append(p)
|
|
313
|
+
|
|
314
|
+
# Get return type
|
|
315
|
+
ret_type = ""
|
|
316
|
+
if hasattr(tool_obj, "fn") and tool_obj.fn:
|
|
317
|
+
try:
|
|
318
|
+
sig = inspect.signature(tool_obj.fn)
|
|
319
|
+
if sig.return_annotation != inspect.Parameter.empty:
|
|
320
|
+
ret = sig.return_annotation
|
|
321
|
+
ret_name = ret.__name__ if hasattr(ret, "__name__") else str(ret)
|
|
322
|
+
ret_type = f" -> {click.style(ret_name, fg='magenta')}"
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
name_s = click.style(tool_obj.name, fg="green", bold=True)
|
|
327
|
+
if multiline and len(params) > 2:
|
|
328
|
+
param_indent = indent + " "
|
|
329
|
+
params_str = ",\n".join(f"{param_indent}{p}" for p in params)
|
|
330
|
+
return f"{indent}{name_s}(\n{params_str}\n{indent}){ret_type}"
|
|
331
|
+
return f"{indent}{name_s}({', '.join(params)}){ret_type}"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def run_mcp_server(transport: str, host: str, port: int):
|
|
335
|
+
"""Internal function to run MCP server."""
|
|
336
|
+
try:
|
|
337
|
+
from .mcp_server import run_server
|
|
338
|
+
except ImportError:
|
|
339
|
+
click.echo(
|
|
340
|
+
"MCP server requires fastmcp. Install with:\n"
|
|
341
|
+
" pip install crossref-local[mcp]",
|
|
342
|
+
err=True,
|
|
343
|
+
)
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
run_server(transport=transport, host=host, port=port)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def register_mcp_commands(cli_group):
|
|
350
|
+
"""Register MCP commands with the main CLI group."""
|
|
351
|
+
cli_group.add_command(mcp)
|