crossref-local 0.4.0__py3-none-any.whl → 0.5.1__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.
Files changed (50) hide show
  1. crossref_local/__init__.py +24 -10
  2. crossref_local/_aio/__init__.py +30 -0
  3. crossref_local/_aio/_impl.py +238 -0
  4. crossref_local/_cache/__init__.py +15 -0
  5. crossref_local/{cache_export.py → _cache/export.py} +27 -10
  6. crossref_local/_cache/utils.py +93 -0
  7. crossref_local/_cli/__init__.py +9 -0
  8. crossref_local/_cli/cli.py +389 -0
  9. crossref_local/_cli/mcp.py +351 -0
  10. crossref_local/_cli/mcp_server.py +457 -0
  11. crossref_local/_cli/search.py +199 -0
  12. crossref_local/_core/__init__.py +62 -0
  13. crossref_local/{api.py → _core/api.py} +26 -5
  14. crossref_local/{citations.py → _core/citations.py} +55 -26
  15. crossref_local/{config.py → _core/config.py} +40 -22
  16. crossref_local/{db.py → _core/db.py} +32 -26
  17. crossref_local/_core/export.py +344 -0
  18. crossref_local/{fts.py → _core/fts.py} +37 -14
  19. crossref_local/{models.py → _core/models.py} +120 -6
  20. crossref_local/_remote/__init__.py +56 -0
  21. crossref_local/_remote/base.py +378 -0
  22. crossref_local/_remote/collections.py +175 -0
  23. crossref_local/_server/__init__.py +140 -0
  24. crossref_local/_server/middleware.py +25 -0
  25. crossref_local/_server/models.py +143 -0
  26. crossref_local/_server/routes_citations.py +98 -0
  27. crossref_local/_server/routes_collections.py +282 -0
  28. crossref_local/_server/routes_compat.py +102 -0
  29. crossref_local/_server/routes_works.py +178 -0
  30. crossref_local/_server/server.py +19 -0
  31. crossref_local/aio.py +30 -206
  32. crossref_local/cache.py +100 -100
  33. crossref_local/cli.py +5 -515
  34. crossref_local/jobs.py +169 -0
  35. crossref_local/mcp_server.py +5 -410
  36. crossref_local/remote.py +5 -266
  37. crossref_local/server.py +5 -349
  38. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/METADATA +36 -11
  39. crossref_local-0.5.1.dist-info/RECORD +49 -0
  40. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/entry_points.txt +1 -1
  41. crossref_local/cli_mcp.py +0 -275
  42. crossref_local-0.4.0.dist-info/RECORD +0 -27
  43. /crossref_local/{cache_viz.py → _cache/viz.py} +0 -0
  44. /crossref_local/{cli_cache.py → _cli/cache.py} +0 -0
  45. /crossref_local/{cli_completion.py → _cli/completion.py} +0 -0
  46. /crossref_local/{cli_main.py → _cli/main.py} +0 -0
  47. /crossref_local/{impact_factor → _impact_factor}/__init__.py +0 -0
  48. /crossref_local/{impact_factor → _impact_factor}/calculator.py +0 -0
  49. /crossref_local/{impact_factor → _impact_factor}/journal_lookup.py +0 -0
  50. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/WHEEL +0 -0
@@ -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)