logodev-mcp 1.0.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.
- logodev_mcp/__init__.py +6 -0
- logodev_mcp/_server_apps.py +37 -0
- logodev_mcp/_server_deps.py +49 -0
- logodev_mcp/cli.py +118 -0
- logodev_mcp/config.py +48 -0
- logodev_mcp/domain.py +270 -0
- logodev_mcp/prompts.py +21 -0
- logodev_mcp/resources.py +34 -0
- logodev_mcp/server.py +121 -0
- logodev_mcp/static/app.html +13 -0
- logodev_mcp/static/icons/.gitkeep +0 -0
- logodev_mcp/tools.py +163 -0
- logodev_mcp-1.0.0.dist-info/METADATA +224 -0
- logodev_mcp-1.0.0.dist-info/RECORD +17 -0
- logodev_mcp-1.0.0.dist-info/WHEEL +4 -0
- logodev_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- logodev_mcp-1.0.0.dist-info/licenses/LICENSE +55 -0
logodev_mcp/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""MCP Apps scaffolding for Logo.dev MCP.
|
|
2
|
+
|
|
3
|
+
Ships as an inert placeholder: ``register_apps`` is a no-op unless the
|
|
4
|
+
``LOGODEV_MCP_APP_DOMAIN`` or ``LOGODEV_MCP_BASE_URL`` env
|
|
5
|
+
vars are set. Adopt MCP Apps by copying MV's ``_server_apps.py`` as a
|
|
6
|
+
reference once you need a real UI resource.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_ENV_PREFIX = "LOGODEV_MCP"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_apps(_mcp: FastMCP) -> None:
|
|
22
|
+
"""Register MCP Apps resources on *mcp*.
|
|
23
|
+
|
|
24
|
+
This scaffold intentionally registers nothing; check the env var
|
|
25
|
+
and log that the scaffold is inactive. Real projects replace the
|
|
26
|
+
body with resource + tool registrations following MV's pattern.
|
|
27
|
+
"""
|
|
28
|
+
app_domain = (
|
|
29
|
+
os.environ.get(f"{_ENV_PREFIX}_APP_DOMAIN", "").strip()
|
|
30
|
+
or os.environ.get(f"{_ENV_PREFIX}_BASE_URL", "").strip()
|
|
31
|
+
)
|
|
32
|
+
if app_domain:
|
|
33
|
+
logger.info(
|
|
34
|
+
"MCP Apps scaffold present but not wired — app_domain=%s", app_domain
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
logger.debug("MCP Apps scaffold inactive (no app_domain configured)")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Service lifespan + dependency injection for Logo.dev MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any, TypedDict
|
|
9
|
+
|
|
10
|
+
from fastmcp.dependencies import CurrentContext
|
|
11
|
+
from fastmcp.server.context import Context
|
|
12
|
+
|
|
13
|
+
from logodev_mcp.domain import Service
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LifespanState(TypedDict):
|
|
19
|
+
"""Shape of the lifespan context yielded to request handlers."""
|
|
20
|
+
|
|
21
|
+
service: Service
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@asynccontextmanager
|
|
25
|
+
async def server_lifespan(_mcp: object) -> AsyncIterator[dict[str, Any]]:
|
|
26
|
+
"""Start the service on startup; stop it on shutdown."""
|
|
27
|
+
service = Service()
|
|
28
|
+
await service.start()
|
|
29
|
+
logger.info("Service started")
|
|
30
|
+
try:
|
|
31
|
+
yield {"service": service}
|
|
32
|
+
finally:
|
|
33
|
+
await service.stop()
|
|
34
|
+
logger.info("Service stopped")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_service(ctx: Context = CurrentContext()) -> Service:
|
|
38
|
+
"""Resolve the running :class:`Service` from the request context.
|
|
39
|
+
|
|
40
|
+
Use as a ``Depends`` default in tool/resource/prompt handlers.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
RuntimeError: If the server lifespan has not run.
|
|
44
|
+
"""
|
|
45
|
+
service: Service | None = ctx.lifespan_context.get("service")
|
|
46
|
+
if service is None:
|
|
47
|
+
msg = "Service not initialised — server lifespan has not run"
|
|
48
|
+
raise RuntimeError(msg)
|
|
49
|
+
return service
|
logodev_mcp/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command-line interface for Logo.dev MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from fastmcp_pvl_core import (
|
|
10
|
+
build_event_store,
|
|
11
|
+
configure_logging_from_env,
|
|
12
|
+
maybe_start_debugpy,
|
|
13
|
+
normalise_http_path,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from logodev_mcp.config import _ENV_PREFIX, ProjectConfig
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="logodev-mcp",
|
|
20
|
+
help="Look up company logos and brand data via the logo.dev API.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Transport = Literal["stdio", "http", "sse"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def _root(
|
|
30
|
+
verbose: bool = typer.Option(
|
|
31
|
+
False, "-v", "--verbose", help="Enable debug logging."
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Root callback — bootstraps logging for every subcommand.
|
|
35
|
+
|
|
36
|
+
``configure_logging_from_env`` sets the root logger *level* and
|
|
37
|
+
configures FastMCP's own logger tree, but does NOT attach a handler
|
|
38
|
+
to the root logger — so ``logodev_mcp.*`` loggers would have
|
|
39
|
+
no output. Attach one here. Kept idempotent via the
|
|
40
|
+
``if not root.handlers`` guard so repeated calls (e.g. from
|
|
41
|
+
``make_server()`` on the same process) are safe.
|
|
42
|
+
"""
|
|
43
|
+
configure_logging_from_env(verbose=verbose)
|
|
44
|
+
root = logging.getLogger()
|
|
45
|
+
if not root.handlers:
|
|
46
|
+
handler = logging.StreamHandler()
|
|
47
|
+
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
48
|
+
root.addHandler(handler)
|
|
49
|
+
if verbose:
|
|
50
|
+
# httpx/httpcore are noisy at DEBUG; keep them quiet. Core doesn't
|
|
51
|
+
# own these deps, so the silencing stays domain-local.
|
|
52
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
53
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def serve(
|
|
58
|
+
transport: Transport = typer.Option(
|
|
59
|
+
"stdio", help="MCP transport (stdio / http / sse)."
|
|
60
|
+
),
|
|
61
|
+
host: str = typer.Option("0.0.0.0", help="Bind host (http only)."),
|
|
62
|
+
port: int = typer.Option(8000, help="Bind port (http only)."),
|
|
63
|
+
http_path: str | None = typer.Option(
|
|
64
|
+
None,
|
|
65
|
+
"--http-path",
|
|
66
|
+
"--path",
|
|
67
|
+
help=(f"Mount path (http only, default: ${_ENV_PREFIX}_HTTP_PATH or /mcp)."),
|
|
68
|
+
),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Run the MCP server."""
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
from logodev_mcp.server import make_server
|
|
74
|
+
|
|
75
|
+
# Optional remote-debugger listener — placed in ``serve`` (not the
|
|
76
|
+
# typer root callback) so non-server commands like ``--help``,
|
|
77
|
+
# ``--version``, or future ``dump-config``-style subcommands are
|
|
78
|
+
# never blocked by ``LOGODEV_MCP_DEBUG_WAIT=true``. No-op
|
|
79
|
+
# unless ``LOGODEV_MCP_DEBUG_PORT`` is set; ``debugpy`` is only
|
|
80
|
+
# present when the image was built with ``--build-arg DEBUG=true``
|
|
81
|
+
# (a missing import logs a WARNING and continues). ``_root`` has
|
|
82
|
+
# already attached the StreamHandler by the time ``serve`` runs, so
|
|
83
|
+
# the helper's INFO/WARNING logs route through the configured
|
|
84
|
+
# formatter rather than Python's lastResort.
|
|
85
|
+
maybe_start_debugpy(_ENV_PREFIX)
|
|
86
|
+
|
|
87
|
+
config = ProjectConfig.from_env()
|
|
88
|
+
server = make_server(transport=transport, config=config)
|
|
89
|
+
|
|
90
|
+
if transport == "http":
|
|
91
|
+
import uvicorn
|
|
92
|
+
|
|
93
|
+
path = normalise_http_path(
|
|
94
|
+
http_path or os.environ.get(f"{_ENV_PREFIX}_HTTP_PATH")
|
|
95
|
+
)
|
|
96
|
+
event_store = build_event_store(_ENV_PREFIX, config.server)
|
|
97
|
+
# lifespan="on" is essential: FastMCP's server_lifespan (startup/shutdown
|
|
98
|
+
# hooks, including service init) runs through the ASGI lifespan protocol.
|
|
99
|
+
# timeout_graceful_shutdown=3 lets SIGTERM drain requests within 3s so
|
|
100
|
+
# containers (Docker/k8s) stop cleanly.
|
|
101
|
+
uvicorn.run(
|
|
102
|
+
server.http_app(path=path, event_store=event_store),
|
|
103
|
+
host=host,
|
|
104
|
+
port=port,
|
|
105
|
+
lifespan="on",
|
|
106
|
+
timeout_graceful_shutdown=3,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
server.run(transport=transport)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> None:
|
|
113
|
+
"""CLI entry point — used by ``[project.scripts]`` in pyproject.toml."""
|
|
114
|
+
app()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
logodev_mcp/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Configuration for Logo.dev MCP.
|
|
2
|
+
|
|
3
|
+
Composes :class:`fastmcp_pvl_core.ServerConfig` via the domain
|
|
4
|
+
:class:`ProjectConfig` dataclass — never inherits.
|
|
5
|
+
|
|
6
|
+
Add domain-specific fields between the CONFIG-FIELDS sentinels; copier
|
|
7
|
+
update preserves that block across template updates.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from fastmcp_pvl_core import (
|
|
15
|
+
ServerConfig,
|
|
16
|
+
env,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ENV_PREFIX = "LOGODEV_MCP"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ProjectConfig:
|
|
24
|
+
"""Domain config for Logo.dev MCP. Compose — don't inherit."""
|
|
25
|
+
|
|
26
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
27
|
+
|
|
28
|
+
# CONFIG-FIELDS-START — add domain fields below; kept across copier update
|
|
29
|
+
# (uncommenting the Path-typed examples below also requires adding
|
|
30
|
+
# ``from pathlib import Path`` to the imports at the top of this file.)
|
|
31
|
+
# (example)
|
|
32
|
+
# vault_path: Path = Path("/data/vault")
|
|
33
|
+
publishable_key: str | None = None
|
|
34
|
+
secret_key: str | None = None
|
|
35
|
+
# CONFIG-FIELDS-END
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_env(cls) -> ProjectConfig:
|
|
39
|
+
"""Load :class:`ProjectConfig` from ``LOGODEV_MCP_*`` env vars."""
|
|
40
|
+
return cls(
|
|
41
|
+
server=ServerConfig.from_env(_ENV_PREFIX),
|
|
42
|
+
# CONFIG-FROM-ENV-START — populate domain fields below; kept across copier update
|
|
43
|
+
# (example)
|
|
44
|
+
# vault_path=Path(env(_ENV_PREFIX, "VAULT_PATH", "/data/vault")),
|
|
45
|
+
publishable_key=env(_ENV_PREFIX, "PUBLISHABLE_KEY"),
|
|
46
|
+
secret_key=env(_ENV_PREFIX, "SECRET_KEY"),
|
|
47
|
+
# CONFIG-FROM-ENV-END
|
|
48
|
+
)
|
logodev_mcp/domain.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Domain logic for Logo.dev MCP — a thin async client over the logo.dev API.
|
|
2
|
+
|
|
3
|
+
Plain Python only: no FastMCP types here so the logic is unit-testable
|
|
4
|
+
without a server. Tool wrappers in ``tools.py`` adapt these methods to
|
|
5
|
+
MCP (error strings, ``Image`` content).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from logodev_mcp.config import ProjectConfig
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
API_BASE = "https://api.logo.dev"
|
|
21
|
+
IMG_BASE = "https://img.logo.dev"
|
|
22
|
+
|
|
23
|
+
_IDENTIFIER_PATHS = {
|
|
24
|
+
"domain": "/{ident}",
|
|
25
|
+
"ticker": "/ticker/{ident}",
|
|
26
|
+
"isin": "/isin/{ident}",
|
|
27
|
+
"crypto": "/crypto/{ident}",
|
|
28
|
+
"name": "/name/{ident}",
|
|
29
|
+
}
|
|
30
|
+
_FORMATS = ("jpg", "png", "webp")
|
|
31
|
+
_THEMES = ("auto", "light", "dark")
|
|
32
|
+
# "404" is passed through to logo.dev as the ``fallback`` query value (the
|
|
33
|
+
# alternative to "monogram"): it asks for an HTTP 404 rather than a placeholder
|
|
34
|
+
# image when no logo exists for the identifier.
|
|
35
|
+
_FALLBACKS = ("monogram", "404")
|
|
36
|
+
_STRATEGIES = ("suggest", "match")
|
|
37
|
+
|
|
38
|
+
_TIMEOUT = 30.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LogoDevError(Exception):
|
|
42
|
+
"""A logo.dev request failed validation or returned an error response."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.message = message
|
|
47
|
+
self.status = status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Service:
|
|
51
|
+
"""Async client over the logo.dev REST and image APIs.
|
|
52
|
+
|
|
53
|
+
Pass an explicit :class:`ProjectConfig` for tests; in production the
|
|
54
|
+
lifespan constructs ``Service()`` with no args and :meth:`start` loads
|
|
55
|
+
config from the environment.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: ProjectConfig | None = None) -> None:
|
|
59
|
+
self._config = config
|
|
60
|
+
self._api: httpx.AsyncClient | None = None
|
|
61
|
+
self._img: httpx.AsyncClient | None = None
|
|
62
|
+
self.has_publishable = False
|
|
63
|
+
self.has_secret = False
|
|
64
|
+
|
|
65
|
+
async def start(self) -> None:
|
|
66
|
+
"""Load config (if not injected) and build the configured clients.
|
|
67
|
+
|
|
68
|
+
Re-entrant: if called while already started, the existing clients are
|
|
69
|
+
closed first so their connection pools are not leaked.
|
|
70
|
+
"""
|
|
71
|
+
if self._api is not None or self._img is not None:
|
|
72
|
+
await self.stop()
|
|
73
|
+
config = self._config or ProjectConfig.from_env()
|
|
74
|
+
self._config = config
|
|
75
|
+
self.has_publishable = bool(config.publishable_key)
|
|
76
|
+
self.has_secret = bool(config.secret_key)
|
|
77
|
+
|
|
78
|
+
if self.has_secret:
|
|
79
|
+
self._api = httpx.AsyncClient(
|
|
80
|
+
base_url=API_BASE,
|
|
81
|
+
headers={"Authorization": f"Bearer {config.secret_key}"},
|
|
82
|
+
timeout=_TIMEOUT,
|
|
83
|
+
)
|
|
84
|
+
if self.has_publishable:
|
|
85
|
+
self._img = httpx.AsyncClient(base_url=IMG_BASE, timeout=_TIMEOUT)
|
|
86
|
+
|
|
87
|
+
# Report each API by its client object, not by the secret-named flags.
|
|
88
|
+
# has_secret is only a bool (no key value), but CodeQL's
|
|
89
|
+
# py/clear-text-logging-sensitive-data heuristic still flags the
|
|
90
|
+
# config.secret_key -> has_secret -> log-sink path; logging client
|
|
91
|
+
# presence avoids that false positive.
|
|
92
|
+
logger.info(
|
|
93
|
+
"service_started image_api=%s rest_api=%s",
|
|
94
|
+
self._img is not None,
|
|
95
|
+
self._api is not None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def stop(self) -> None:
|
|
99
|
+
"""Close any open clients. Safe to call more than once."""
|
|
100
|
+
# Null the references first so a failure mid-close cannot leave a
|
|
101
|
+
# half-closed client reachable, and a second stop() is always a no-op.
|
|
102
|
+
api, img = self._api, self._img
|
|
103
|
+
self._api = None
|
|
104
|
+
self._img = None
|
|
105
|
+
for client in (api, img):
|
|
106
|
+
if client is not None:
|
|
107
|
+
await client.aclose()
|
|
108
|
+
|
|
109
|
+
# --- internal helpers ---
|
|
110
|
+
|
|
111
|
+
def _raise_for_status(self, resp: httpx.Response, *, subject: str) -> None:
|
|
112
|
+
"""Map an error response to a :class:`LogoDevError`; no-op on success."""
|
|
113
|
+
code = resp.status_code
|
|
114
|
+
if code < 400:
|
|
115
|
+
return
|
|
116
|
+
# Log server-side before surfacing the user-facing string — the caller
|
|
117
|
+
# only sees the returned message, so this is the operator's trace.
|
|
118
|
+
logger.warning("logodev_http_error status=%s subject=%s", code, subject)
|
|
119
|
+
if code in (401, 403):
|
|
120
|
+
raise LogoDevError(
|
|
121
|
+
"Authentication or plan-tier problem — check your "
|
|
122
|
+
"LOGODEV_MCP_SECRET_KEY and that your plan includes this "
|
|
123
|
+
"endpoint.",
|
|
124
|
+
status=code,
|
|
125
|
+
)
|
|
126
|
+
if code == 404:
|
|
127
|
+
raise LogoDevError(f"No result found for {subject!r}.", status=404)
|
|
128
|
+
if code == 429:
|
|
129
|
+
raise LogoDevError("logo.dev rate limit reached — retry later.", status=429)
|
|
130
|
+
raise LogoDevError(f"logo.dev error: {code} {resp.text}", status=code)
|
|
131
|
+
|
|
132
|
+
async def _get(
|
|
133
|
+
self,
|
|
134
|
+
client: httpx.AsyncClient,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
params: dict[str, Any] | None = None,
|
|
138
|
+
subject: str,
|
|
139
|
+
) -> httpx.Response:
|
|
140
|
+
"""GET with transport-error translation and status mapping."""
|
|
141
|
+
try:
|
|
142
|
+
resp = await client.get(path, params=params)
|
|
143
|
+
except httpx.TimeoutException as exc:
|
|
144
|
+
logger.warning("logodev_timeout subject=%s", subject)
|
|
145
|
+
raise LogoDevError("logo.dev did not respond in time.") from exc
|
|
146
|
+
except httpx.TransportError as exc:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"logodev_transport_error subject=%s error=%s",
|
|
149
|
+
subject,
|
|
150
|
+
type(exc).__name__,
|
|
151
|
+
)
|
|
152
|
+
raise LogoDevError("Cannot reach logo.dev.") from exc
|
|
153
|
+
self._raise_for_status(resp, subject=subject)
|
|
154
|
+
return resp
|
|
155
|
+
|
|
156
|
+
async def get_logo(
|
|
157
|
+
self,
|
|
158
|
+
identifier: str,
|
|
159
|
+
*,
|
|
160
|
+
identifier_type: str = "domain",
|
|
161
|
+
size: int = 128,
|
|
162
|
+
image_format: str = "png",
|
|
163
|
+
theme: str = "auto",
|
|
164
|
+
greyscale: bool = False,
|
|
165
|
+
retina: bool = False,
|
|
166
|
+
fallback: str = "monogram",
|
|
167
|
+
url_only: bool = False,
|
|
168
|
+
) -> tuple[str, bytes | None]:
|
|
169
|
+
"""Build the img.logo.dev URL and (unless ``url_only``) fetch the bytes."""
|
|
170
|
+
# The _config/_img None checks also narrow them to non-None for mypy below.
|
|
171
|
+
if not self.has_publishable or self._config is None or self._img is None:
|
|
172
|
+
raise LogoDevError(
|
|
173
|
+
"Logo API not configured — set LOGODEV_MCP_PUBLISHABLE_KEY."
|
|
174
|
+
)
|
|
175
|
+
if identifier_type not in _IDENTIFIER_PATHS:
|
|
176
|
+
raise LogoDevError(
|
|
177
|
+
f"Invalid identifier_type {identifier_type!r}; expected one of "
|
|
178
|
+
f"{list(_IDENTIFIER_PATHS)}."
|
|
179
|
+
)
|
|
180
|
+
if not 1 <= size <= 800:
|
|
181
|
+
raise LogoDevError("size must be between 1 and 800.")
|
|
182
|
+
if image_format not in _FORMATS:
|
|
183
|
+
raise LogoDevError(
|
|
184
|
+
f"Invalid format {image_format!r}; expected one of {list(_FORMATS)}."
|
|
185
|
+
)
|
|
186
|
+
if theme not in _THEMES:
|
|
187
|
+
raise LogoDevError(
|
|
188
|
+
f"Invalid theme {theme!r}; expected one of {list(_THEMES)}."
|
|
189
|
+
)
|
|
190
|
+
if fallback not in _FALLBACKS:
|
|
191
|
+
raise LogoDevError(
|
|
192
|
+
f"Invalid fallback {fallback!r}; expected one of {list(_FALLBACKS)}."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
path = _IDENTIFIER_PATHS[identifier_type].format(
|
|
196
|
+
ident=quote(identifier, safe="")
|
|
197
|
+
)
|
|
198
|
+
params: dict[str, Any] = {
|
|
199
|
+
"token": self._config.publishable_key,
|
|
200
|
+
"format": image_format,
|
|
201
|
+
}
|
|
202
|
+
if size != 128:
|
|
203
|
+
params["size"] = size
|
|
204
|
+
if theme != "auto":
|
|
205
|
+
params["theme"] = theme
|
|
206
|
+
if greyscale:
|
|
207
|
+
params["greyscale"] = "true"
|
|
208
|
+
if retina:
|
|
209
|
+
params["retina"] = "true"
|
|
210
|
+
if fallback != "monogram":
|
|
211
|
+
params["fallback"] = fallback
|
|
212
|
+
|
|
213
|
+
url = str(httpx.URL(IMG_BASE + path, params=params))
|
|
214
|
+
if url_only:
|
|
215
|
+
return url, None
|
|
216
|
+
|
|
217
|
+
resp = await self._get(self._img, path, params=params, subject=identifier)
|
|
218
|
+
return url, resp.content
|
|
219
|
+
|
|
220
|
+
def _require_secret(self) -> httpx.AsyncClient:
|
|
221
|
+
"""Return the api client, or raise if the secret key is unconfigured."""
|
|
222
|
+
if not self.has_secret or self._api is None:
|
|
223
|
+
raise LogoDevError(
|
|
224
|
+
"This endpoint needs a secret key — set LOGODEV_MCP_SECRET_KEY "
|
|
225
|
+
"(Search/Describe/Brand require a paid plan)."
|
|
226
|
+
)
|
|
227
|
+
return self._api
|
|
228
|
+
|
|
229
|
+
def _json(self, resp: httpx.Response, *, subject: str) -> Any:
|
|
230
|
+
"""Parse a JSON body, mapping a malformed payload to a LogoDevError."""
|
|
231
|
+
try:
|
|
232
|
+
return resp.json()
|
|
233
|
+
except ValueError as exc: # json.JSONDecodeError is a ValueError subclass
|
|
234
|
+
logger.warning("logodev_bad_json subject=%s", subject)
|
|
235
|
+
raise LogoDevError(
|
|
236
|
+
"logo.dev returned a response that could not be parsed."
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
async def search_brands(self, query: str, *, strategy: str = "suggest") -> Any:
|
|
240
|
+
"""Resolve a brand/company name to candidate domains."""
|
|
241
|
+
api = self._require_secret()
|
|
242
|
+
if strategy not in _STRATEGIES:
|
|
243
|
+
raise LogoDevError(
|
|
244
|
+
f"Invalid strategy {strategy!r}; expected one of {list(_STRATEGIES)}."
|
|
245
|
+
)
|
|
246
|
+
if not query.strip():
|
|
247
|
+
raise LogoDevError("query must not be empty.")
|
|
248
|
+
params: dict[str, Any] = {"q": query}
|
|
249
|
+
if strategy != "suggest":
|
|
250
|
+
params["strategy"] = strategy
|
|
251
|
+
resp = await self._get(api, "/search", params=params, subject=query)
|
|
252
|
+
return self._json(resp, subject=query)
|
|
253
|
+
|
|
254
|
+
async def describe_company(self, domain: str) -> Any:
|
|
255
|
+
"""Return structured company data for a domain."""
|
|
256
|
+
api = self._require_secret()
|
|
257
|
+
if not domain.strip():
|
|
258
|
+
raise LogoDevError("domain must not be empty.")
|
|
259
|
+
resp = await self._get(
|
|
260
|
+
api, f"/describe/{quote(domain, safe='')}", subject=domain
|
|
261
|
+
)
|
|
262
|
+
return self._json(resp, subject=domain)
|
|
263
|
+
|
|
264
|
+
async def get_brand(self, domain: str) -> Any:
|
|
265
|
+
"""Return the full brand profile for a domain."""
|
|
266
|
+
api = self._require_secret()
|
|
267
|
+
if not domain.strip():
|
|
268
|
+
raise LogoDevError("domain must not be empty.")
|
|
269
|
+
resp = await self._get(api, f"/brand/{quote(domain, safe='')}", subject=domain)
|
|
270
|
+
return self._json(resp, subject=domain)
|
logodev_mcp/prompts.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Prompt registrations for Logo.dev MCP.
|
|
2
|
+
|
|
3
|
+
See FastMCP prompt docs: https://gofastmcp.com/servers/prompts
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_prompts(mcp: FastMCP) -> None:
|
|
12
|
+
"""Register all domain prompts on *mcp*."""
|
|
13
|
+
|
|
14
|
+
@mcp.prompt()
|
|
15
|
+
async def summarize(context: str) -> str:
|
|
16
|
+
"""Summarize ``context`` in one paragraph.
|
|
17
|
+
|
|
18
|
+
See https://gofastmcp.com/servers/prompts#prompt-arguments for
|
|
19
|
+
the full signature surface.
|
|
20
|
+
"""
|
|
21
|
+
return f"Summarize the following in one paragraph:\n\n{context}"
|
logodev_mcp/resources.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Resource registrations for Logo.dev MCP.
|
|
2
|
+
|
|
3
|
+
See FastMCP resource docs: https://gofastmcp.com/servers/resources
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from fastmcp.dependencies import Depends
|
|
10
|
+
|
|
11
|
+
from logodev_mcp._server_deps import get_service
|
|
12
|
+
from logodev_mcp.domain import Service
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_resources(mcp: FastMCP) -> None:
|
|
16
|
+
"""Register all domain resources on *mcp*."""
|
|
17
|
+
|
|
18
|
+
@mcp.resource("status://logodev-mcp")
|
|
19
|
+
async def status(service: Service = Depends(get_service)) -> dict[str, object]:
|
|
20
|
+
"""Service status resource — JSON-serialisable dict.
|
|
21
|
+
|
|
22
|
+
Templated resources take path parameters in the URI; static
|
|
23
|
+
resources don't. See
|
|
24
|
+
https://gofastmcp.com/servers/resources#templates for the full
|
|
25
|
+
pattern.
|
|
26
|
+
"""
|
|
27
|
+
return {
|
|
28
|
+
# "ready" reflects an operable service: started AND at least one
|
|
29
|
+
# API key configured, so a keyless (no-tool) deployment reports
|
|
30
|
+
# not-ready rather than masking the misconfiguration.
|
|
31
|
+
"ready": service.has_publishable or service.has_secret,
|
|
32
|
+
"has_publishable": service.has_publishable,
|
|
33
|
+
"has_secret": service.has_secret,
|
|
34
|
+
}
|
logodev_mcp/server.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Logo.dev MCP — FastMCP server entry point.
|
|
2
|
+
|
|
3
|
+
Composes the primitives from ``fastmcp-pvl-core`` into a
|
|
4
|
+
project-specific ``make_server()``. See
|
|
5
|
+
https://gofastmcp.com/servers for the FastMCP server surface and
|
|
6
|
+
``fastmcp-pvl-core``'s README for the composable helpers used below.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from importlib.metadata import PackageNotFoundError
|
|
13
|
+
from importlib.metadata import version as _pkg_version
|
|
14
|
+
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
from fastmcp_pvl_core import (
|
|
17
|
+
ServerConfig, # noqa: F401 — re-exported for downstream projects' convenience
|
|
18
|
+
build_auth,
|
|
19
|
+
build_event_store, # noqa: F401 — re-exported for downstream projects' convenience
|
|
20
|
+
build_instructions,
|
|
21
|
+
build_kv_store, # noqa: F401 — re-exported for downstream projects' convenience
|
|
22
|
+
configure_logging_from_env,
|
|
23
|
+
register_server_info_tool,
|
|
24
|
+
resolve_auth_mode,
|
|
25
|
+
wire_middleware_stack,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from logodev_mcp._server_apps import register_apps
|
|
29
|
+
from logodev_mcp._server_deps import server_lifespan
|
|
30
|
+
from logodev_mcp.config import ProjectConfig
|
|
31
|
+
from logodev_mcp.prompts import register_prompts
|
|
32
|
+
from logodev_mcp.resources import register_resources
|
|
33
|
+
from logodev_mcp.tools import register_tools
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_ENV_PREFIX = "LOGODEV_MCP"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_server(
|
|
41
|
+
*,
|
|
42
|
+
transport: str = "stdio",
|
|
43
|
+
config: ProjectConfig | None = None,
|
|
44
|
+
) -> FastMCP:
|
|
45
|
+
"""Construct the Logo.dev MCP FastMCP server.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
transport: ``"stdio"`` / ``"http"`` / ``"sse"``. Used here for
|
|
49
|
+
logging only.
|
|
50
|
+
config: Optional pre-loaded config; default loads from env.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A configured :class:`fastmcp.FastMCP` instance.
|
|
54
|
+
"""
|
|
55
|
+
config = config or ProjectConfig.from_env()
|
|
56
|
+
configure_logging_from_env()
|
|
57
|
+
|
|
58
|
+
auth = build_auth(config.server)
|
|
59
|
+
auth_mode = resolve_auth_mode(config.server) if auth is not None else "none"
|
|
60
|
+
if auth_mode == "none":
|
|
61
|
+
logger.warning(
|
|
62
|
+
"No auth configured — server accepts unauthenticated connections"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
logger.info("Auth enabled: mode=%s", auth_mode)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
pkg_ver = _pkg_version("logodev-mcp")
|
|
69
|
+
except PackageNotFoundError:
|
|
70
|
+
pkg_ver = "unknown"
|
|
71
|
+
|
|
72
|
+
logger.info(
|
|
73
|
+
"Server config: version=%s name=logodev-mcp transport=%s auth=%s",
|
|
74
|
+
pkg_ver,
|
|
75
|
+
transport,
|
|
76
|
+
auth_mode,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
mcp = FastMCP(
|
|
80
|
+
name="logodev-mcp",
|
|
81
|
+
instructions=build_instructions(
|
|
82
|
+
read_only=True,
|
|
83
|
+
env_prefix=_ENV_PREFIX,
|
|
84
|
+
domain_line="Look up company logos and brand data via the logo.dev API.",
|
|
85
|
+
),
|
|
86
|
+
lifespan=server_lifespan,
|
|
87
|
+
auth=auth,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
wire_middleware_stack(mcp)
|
|
91
|
+
|
|
92
|
+
register_tools(mcp, config=config)
|
|
93
|
+
register_resources(mcp)
|
|
94
|
+
register_prompts(mcp)
|
|
95
|
+
register_apps(mcp)
|
|
96
|
+
|
|
97
|
+
register_server_info_tool(
|
|
98
|
+
mcp,
|
|
99
|
+
server_name="logodev-mcp",
|
|
100
|
+
server_version=pkg_ver,
|
|
101
|
+
# DOMAIN-UPSTREAM-START — wire upstream version reporting for servers
|
|
102
|
+
# that talk to a remote service (paperless-mcp, etc.). The provider is
|
|
103
|
+
# a zero-arg callable; the simplest pattern is a module-level upstream
|
|
104
|
+
# client (typically constructed from env vars at import time) whose
|
|
105
|
+
# version method is referenced here. ``CurrentContext()`` is a FastMCP
|
|
106
|
+
# DI marker — it only resolves to a live context when used as a
|
|
107
|
+
# parameter default in a tool/resource handler, so it cannot be called
|
|
108
|
+
# directly from a zero-arg provider.
|
|
109
|
+
# Uncomment the kwargs below as additional arguments to this call:
|
|
110
|
+
# upstream_version=lambda: _upstream_client.remote_version(),
|
|
111
|
+
# upstream_label="paperless",
|
|
112
|
+
# DOMAIN-UPSTREAM-END
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# DOMAIN-WIRING-START — project-specific wiring (custom HTTP routes,
|
|
116
|
+
# transforms, mode toggles, alternative middleware, additional registrations);
|
|
117
|
+
# kept across copier update. Leave empty for projects that don't customise
|
|
118
|
+
# make_server() beyond the standard scaffold.
|
|
119
|
+
# DOMAIN-WIRING-END
|
|
120
|
+
|
|
121
|
+
return mcp
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!-- Logo.dev MCP SPA shell placeholder.
|
|
3
|
+
Run scripts/vendor_spa.py to hydrate this file with the vendored
|
|
4
|
+
SDK once you're ready to ship MCP Apps UI. -->
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<title>Logo.dev MCP</title>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<p>SPA placeholder — populate via scripts/vendor_spa.py.</p>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
File without changes
|
logodev_mcp/tools.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tool registrations for Logo.dev MCP.
|
|
2
|
+
|
|
3
|
+
See FastMCP tool docs: https://gofastmcp.com/servers/tools
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
from fastmcp.dependencies import Depends
|
|
14
|
+
from fastmcp.utilities.types import Image
|
|
15
|
+
|
|
16
|
+
from logodev_mcp._server_deps import get_service
|
|
17
|
+
from logodev_mcp.config import ProjectConfig
|
|
18
|
+
from logodev_mcp.domain import LogoDevError, Service
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_IMAGE_FORMATS = {"jpg": "jpeg", "png": "png", "webp": "webp"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_tools(mcp: FastMCP, config: ProjectConfig | None = None) -> None:
|
|
26
|
+
"""Register logo.dev tools, gated by which API keys are configured.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
mcp: The FastMCP server to register tools on.
|
|
30
|
+
config: Project configuration; loaded from the environment when omitted.
|
|
31
|
+
"""
|
|
32
|
+
config = config or ProjectConfig.from_env()
|
|
33
|
+
|
|
34
|
+
if config.publishable_key:
|
|
35
|
+
|
|
36
|
+
@mcp.tool(annotations={"readOnlyHint": True})
|
|
37
|
+
async def get_logo(
|
|
38
|
+
identifier: str,
|
|
39
|
+
identifier_type: str = "domain",
|
|
40
|
+
size: int = 128,
|
|
41
|
+
image_format: str = "png",
|
|
42
|
+
theme: str = "auto",
|
|
43
|
+
greyscale: bool = False,
|
|
44
|
+
retina: bool = False,
|
|
45
|
+
fallback: str = "monogram",
|
|
46
|
+
url_only: bool = False,
|
|
47
|
+
service: Service = Depends(get_service),
|
|
48
|
+
# Intentionally ``Any``: a concrete return annotation makes FastMCP
|
|
49
|
+
# 3.4.2 emit an output schema that rejects the mixed text+image
|
|
50
|
+
# content this tool returns.
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Fetch a company logo from logo.dev.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
identifier: Company identifier — a domain (``nike.com``), stock
|
|
56
|
+
ticker (``AAPL``), ISIN (``US0378331005``), crypto symbol
|
|
57
|
+
(``BTC``), or brand name, depending on ``identifier_type``.
|
|
58
|
+
identifier_type: One of ``domain`` (default), ``ticker``,
|
|
59
|
+
``isin``, ``crypto``, ``name``.
|
|
60
|
+
size: Pixel size, 1-800 (default 128).
|
|
61
|
+
image_format: ``png`` (default), ``jpg``, or ``webp``.
|
|
62
|
+
theme: ``auto`` (default), ``light``, or ``dark``.
|
|
63
|
+
greyscale: Render in greyscale.
|
|
64
|
+
retina: Request a 2x retina asset.
|
|
65
|
+
fallback: ``monogram`` (default) renders a placeholder when no
|
|
66
|
+
logo exists; ``404`` returns an error instead.
|
|
67
|
+
url_only: Return only the image URL without fetching the bytes.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The image URL plus the logo image, just the URL string when
|
|
71
|
+
``url_only`` is true, or a plain error message string if the
|
|
72
|
+
logo.dev request fails.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
url, data = await service.get_logo(
|
|
76
|
+
identifier,
|
|
77
|
+
identifier_type=identifier_type,
|
|
78
|
+
size=size,
|
|
79
|
+
image_format=image_format,
|
|
80
|
+
theme=theme,
|
|
81
|
+
greyscale=greyscale,
|
|
82
|
+
retina=retina,
|
|
83
|
+
fallback=fallback,
|
|
84
|
+
url_only=url_only,
|
|
85
|
+
)
|
|
86
|
+
except LogoDevError as exc:
|
|
87
|
+
return exc.message
|
|
88
|
+
if data is None:
|
|
89
|
+
return url
|
|
90
|
+
# .get() fallback keeps a future _FORMATS addition from becoming a
|
|
91
|
+
# KeyError here if its _IMAGE_FORMATS entry is forgotten.
|
|
92
|
+
image_block = Image(
|
|
93
|
+
data=data, format=_IMAGE_FORMATS.get(image_format, image_format)
|
|
94
|
+
)
|
|
95
|
+
return [url, image_block]
|
|
96
|
+
|
|
97
|
+
if config.secret_key:
|
|
98
|
+
|
|
99
|
+
@mcp.tool(annotations={"readOnlyHint": True})
|
|
100
|
+
async def search_brands(
|
|
101
|
+
query: str,
|
|
102
|
+
strategy: str = "suggest",
|
|
103
|
+
service: Service = Depends(get_service),
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Resolve a brand or company name to candidate domains.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
query: The brand/company name to look up.
|
|
109
|
+
strategy: ``suggest`` (default, typeahead) or ``match``.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A JSON array of candidate brands (name, domain, logo URL).
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
return json.dumps(await service.search_brands(query, strategy=strategy))
|
|
116
|
+
except LogoDevError as exc:
|
|
117
|
+
return exc.message
|
|
118
|
+
|
|
119
|
+
@mcp.tool(annotations={"readOnlyHint": True})
|
|
120
|
+
async def describe_company(
|
|
121
|
+
domain: str,
|
|
122
|
+
service: Service = Depends(get_service),
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Return structured company data for a domain.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
domain: The company domain (e.g. ``nike.com``).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A JSON object with name, description, colors, and socials.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
return json.dumps(await service.describe_company(domain))
|
|
134
|
+
except LogoDevError as exc:
|
|
135
|
+
return exc.message
|
|
136
|
+
|
|
137
|
+
@mcp.tool(annotations={"readOnlyHint": True})
|
|
138
|
+
async def get_brand(
|
|
139
|
+
domain: str,
|
|
140
|
+
service: Service = Depends(get_service),
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Return the full brand profile for a domain.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
domain: The company domain (e.g. ``nike.com``).
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
A JSON object with logo, brandmark, banners, colors, and
|
|
149
|
+
description — a richer superset of ``describe_company``.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
return json.dumps(await service.get_brand(domain))
|
|
153
|
+
except LogoDevError as exc:
|
|
154
|
+
return exc.message
|
|
155
|
+
|
|
156
|
+
if not config.publishable_key and not config.secret_key:
|
|
157
|
+
# Scalar key=value pairs per the project logging standard: each value
|
|
158
|
+
# names the env var that enables that API's tools.
|
|
159
|
+
logger.warning(
|
|
160
|
+
"no_api_keys_configured publishable_env=%s secret_env=%s",
|
|
161
|
+
"LOGODEV_MCP_PUBLISHABLE_KEY",
|
|
162
|
+
"LOGODEV_MCP_SECRET_KEY",
|
|
163
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logodev-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Look up company logos and brand data via the logo.dev API.
|
|
5
|
+
Project-URL: Repository, https://github.com/pvliesdonk/logodev-mcp
|
|
6
|
+
Project-URL: Documentation, https://pvliesdonk.github.io/logodev-mcp/
|
|
7
|
+
Project-URL: Issues, https://github.com/pvliesdonk/logodev-mcp/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/pvliesdonk/logodev-mcp/blob/main/CHANGELOG.md
|
|
9
|
+
Author: pvliesdonk
|
|
10
|
+
License-Expression: LicenseRef-Proprietary
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
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.11
|
|
19
|
+
Requires-Dist: fastmcp-pvl-core<5,>=4.0.0
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: typer>=0.12
|
|
22
|
+
Provides-Extra: debug
|
|
23
|
+
Requires-Dist: fastmcp-pvl-core[debug]; extra == 'debug'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Logo.dev MCP
|
|
27
|
+
|
|
28
|
+
[](https://github.com/pvliesdonk/logodev-mcp/actions/workflows/ci.yml) [](https://codecov.io/gh/pvliesdonk/logodev-mcp) [](https://pypi.org/project/logodev-mcp/) [](https://pypi.org/project/logodev-mcp/) [](LICENSE) [](https://github.com/pvliesdonk/logodev-mcp/pkgs/container/logodev-mcp) [](https://pvliesdonk.github.io/logodev-mcp/) [](https://pvliesdonk.github.io/logodev-mcp/llms.txt) [](https://github.com/pvliesdonk/fastmcp-server-template)
|
|
29
|
+
|
|
30
|
+
Look up company logos and brand data via the logo.dev API.
|
|
31
|
+
|
|
32
|
+
**[Documentation](https://pvliesdonk.github.io/logodev-mcp/)** | **[Config wizard](https://pvliesdonk.github.io/logodev-mcp/latest/configuration-generator/)** | **[PyPI](https://pypi.org/project/logodev-mcp/)** | **[Docker](https://github.com/pvliesdonk/logodev-mcp/pkgs/container/logodev-mcp)**
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
<!-- DOMAIN-START -->
|
|
37
|
+
- **Logo retrieval** (`get_logo`) — fetch a company logo as an image plus URL by domain, ticker, ISIN, crypto symbol, or brand name; supports size, format, theme, greyscale, retina, and fallback options. Requires a publishable key (`pk_…`).
|
|
38
|
+
- **Brand search** (`search_brands`) — resolve a brand or company name to candidate domains and logo URLs via typeahead or exact-match. Requires a secret key (`sk_…`).
|
|
39
|
+
- **Company description** (`describe_company`) — return structured company data (name, description, brand colours, social links) for a domain. Requires a secret key (`sk_…`).
|
|
40
|
+
- **Full brand profile** (`get_brand`) — return the complete brand profile (logo, brandmark, banners, colours, description) for a domain — a richer superset of `describe_company`. Requires a secret key (`sk_…`).
|
|
41
|
+
- **Graceful degradation** — tools are registered only when the corresponding API key is present; missing-key tools are hidden from the MCP client rather than registered and failing at call time.
|
|
42
|
+
<!-- DOMAIN-END -->
|
|
43
|
+
|
|
44
|
+
## What you can do with it
|
|
45
|
+
|
|
46
|
+
<!-- DOMAIN-START -->
|
|
47
|
+
With this server mounted in an MCP client (Claude, etc.), you can:
|
|
48
|
+
|
|
49
|
+
- **Fetch a logo** — "Get the logo for stripe.com." Uses `get_logo` (publishable key required).
|
|
50
|
+
- **Identify a brand's domain** — "What domain is behind the brand 'Stripe'?" Uses `search_brands` (secret key required).
|
|
51
|
+
- **Look up company info** — "What colours does Stripe use in their branding?" Uses `describe_company` (secret key required).
|
|
52
|
+
- **Get the full brand kit** — "Show me the complete brand profile for shopify.com." Uses `get_brand` (secret key required).
|
|
53
|
+
- **Logo by ticker** — "Fetch the logo for AAPL." Uses `get_logo` with `identifier_type=ticker` (publishable key required).
|
|
54
|
+
<!-- DOMAIN-END -->
|
|
55
|
+
|
|
56
|
+
<!-- ===== TEMPLATE-OWNED SECTIONS BELOW — DO NOT EDIT; CHANGES WILL BE OVERWRITTEN ON COPIER UPDATE ===== -->
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
### From PyPI
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install logodev-mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If you add optional extras via the `PROJECT-EXTRAS-START` / `PROJECT-EXTRAS-END` sentinels in `pyproject.toml`, document them below:
|
|
67
|
+
|
|
68
|
+
<!-- DOMAIN-START -->
|
|
69
|
+
<!-- List optional extras and their purpose here (e.g. `pip install logodev-mcp[embeddings]`). Kept across copier update. -->
|
|
70
|
+
<!-- DOMAIN-END -->
|
|
71
|
+
|
|
72
|
+
### From source
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/pvliesdonk/logodev-mcp.git
|
|
76
|
+
cd logodev-mcp
|
|
77
|
+
uv sync --all-extras --all-groups
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Docker
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
docker pull ghcr.io/pvliesdonk/logodev-mcp:latest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A `compose.yml` ships at the repo root as a starting point — copy `.env.example` to `.env`, edit, and `docker compose up -d`.
|
|
87
|
+
|
|
88
|
+
To attach a remote Python debugger (development only — the protocol is unauthenticated), see [Remote debugging](docs/deployment/docker.md#remote-debugging).
|
|
89
|
+
|
|
90
|
+
### Linux packages (.deb / .rpm)
|
|
91
|
+
|
|
92
|
+
Download `.deb` or `.rpm` packages from the [GitHub Releases](https://github.com/pvliesdonk/logodev-mcp/releases) page. Both install a hardened systemd unit; env configuration is sourced from `/etc/logodev-mcp/env` (copy from the shipped `/etc/logodev-mcp/env.example`).
|
|
93
|
+
|
|
94
|
+
### Claude Desktop (.mcpb bundle)
|
|
95
|
+
|
|
96
|
+
Download the `.mcpb` bundle from the [GitHub Releases](https://github.com/pvliesdonk/logodev-mcp/releases) page and double-click to install, or run:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
mcpb install logodev-mcp-<version>.mcpb
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Claude Desktop prompts for required env vars via a GUI wizard — no manual JSON editing needed.
|
|
103
|
+
|
|
104
|
+
For manual Claude Desktop configuration and setup options, see [Claude Desktop deployment](docs/deployment/claude-desktop.md).
|
|
105
|
+
|
|
106
|
+
## Quick start
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
logodev-mcp serve # stdio transport
|
|
110
|
+
logodev-mcp serve --transport http --port 8000 # streamable HTTP
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For library usage (embedding the domain logic without the MCP transport), import from the `logodev_mcp` package directly — see the project's domain modules under `src/logodev_mcp/` for entry points.
|
|
114
|
+
|
|
115
|
+
### Server info
|
|
116
|
+
|
|
117
|
+
The server registers a built-in `get_server_info` tool (via `fastmcp_pvl_core.register_server_info_tool`) so operators can confirm the deployed version with a single MCP call. The default response carries `server_name`, `server_version`, and `core_version`. Servers that talk to a remote upstream wire upstream version reporting inside the `DOMAIN-UPSTREAM-START` / `DOMAIN-UPSTREAM-END` sentinel in `src/logodev_mcp/server.py` — see [`CLAUDE.md`](CLAUDE.md#server-info-tool-get_server_info) for the wiring pattern.
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
Core environment variables shared across all `fastmcp-pvl-core`-based services:
|
|
122
|
+
|
|
123
|
+
| Variable | Default | Description |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `FASTMCP_LOG_LEVEL` | `INFO` | Log level for FastMCP internals and app loggers (`DEBUG` / `INFO` / `WARNING` / `ERROR`). The `-v` CLI flag overrides to `DEBUG`. |
|
|
126
|
+
| `FASTMCP_ENABLE_RICH_LOGGING` | `true` | Set to `false` for plain / structured JSON log output. |
|
|
127
|
+
| `LOGODEV_MCP_KV_STORE_URL` | `file:///data/state` | Persistent-state backend URL for pvl-core subsystems — `file:///path` (survives restarts), `memory://` (dev/ephemeral). |
|
|
128
|
+
|
|
129
|
+
Domain-specific variables go below under [Domain configuration](#domain-configuration).
|
|
130
|
+
|
|
131
|
+
## Authentication
|
|
132
|
+
|
|
133
|
+
Callers authenticate via a bearer token or OIDC (mutually exclusive). See the [Authentication guide](docs/guides/authentication.md) for setup, mapped multi-subject tokens, OIDC, and troubleshooting.
|
|
134
|
+
|
|
135
|
+
## Post-scaffold checklist
|
|
136
|
+
|
|
137
|
+
After `copier copy` and `gh repo create --push`:
|
|
138
|
+
|
|
139
|
+
1. **Fill in the DOMAIN blocks** in this README (Features, What you can do with it, Domain configuration, Key design decisions) and in `CLAUDE.md`.
|
|
140
|
+
2. Configure GitHub secrets — see below.
|
|
141
|
+
3. Install dev + docs tooling: `uv sync --all-extras --all-groups`.
|
|
142
|
+
4. Install pre-commit hooks: `uv run pre-commit install`.
|
|
143
|
+
5. Run the gate locally: `uv run pytest -x -q && uv run ruff check --fix . && uv run ruff format . && uv run mypy src/ tests/`.
|
|
144
|
+
6. Push the first commit — CI should be green.
|
|
145
|
+
|
|
146
|
+
## GitHub secrets
|
|
147
|
+
|
|
148
|
+
CI workflows reference three repository secrets. Configure them via **Settings → Secrets and variables → Actions** or with `gh secret set`:
|
|
149
|
+
|
|
150
|
+
| Secret | Used by | How to generate |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `RELEASE_TOKEN` | `release.yml`, `copier-update.yml` | Fine-grained PAT at <https://github.com/settings/personal-access-tokens/new> with `contents: write` and `pull_requests: write` (the `copier-update` cron opens PRs). Scoped to this repo. |
|
|
153
|
+
| `CODECOV_TOKEN` | `ci.yml` | <https://codecov.io> — sign in with GitHub, add the repo, copy the upload token from the repo settings page. |
|
|
154
|
+
| `CLAUDE_CODE_OAUTH_TOKEN` | `claude.yml`, `claude-code-review.yml` | Run `claude setup-token` locally and paste the result. |
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
gh secret set RELEASE_TOKEN
|
|
158
|
+
gh secret set CODECOV_TOKEN
|
|
159
|
+
gh secret set CLAUDE_CODE_OAUTH_TOKEN
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`GITHUB_TOKEN` is auto-provided — no action needed.
|
|
163
|
+
|
|
164
|
+
## Local development
|
|
165
|
+
|
|
166
|
+
The PR gate (matches CI):
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
uv run pytest -x -q # tests
|
|
170
|
+
uv run ruff check --fix . && uv run ruff format . # lint + format
|
|
171
|
+
uv run mypy src/ tests/ # type-check
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Pre-commit runs a subset of the gate on each commit; see `.pre-commit-config.yaml` for details, or [`CLAUDE.md`](CLAUDE.md) for the full Hard PR Acceptance Gates.
|
|
175
|
+
|
|
176
|
+
## Troubleshooting
|
|
177
|
+
|
|
178
|
+
### Moving a scaffolded project
|
|
179
|
+
|
|
180
|
+
`uv sync` creates `.venv/bin/*` scripts with absolute shebangs pointing at the venv Python. If you move the repo after scaffolding (`mv /old/path /new/path`), `uv run pytest` fails with `ModuleNotFoundError: No module named 'fastmcp'` because the stale shebang resolves to a different interpreter than the venv's site-packages.
|
|
181
|
+
|
|
182
|
+
**Fix:**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
rm -rf .venv
|
|
186
|
+
uv sync --all-extras --all-groups
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`uv run python -m pytest` also works as a one-shot workaround (bypasses the stale entry-script shim).
|
|
190
|
+
|
|
191
|
+
### `uv.lock` refresh after `copier update`
|
|
192
|
+
|
|
193
|
+
When `copier update` introduces new dependencies (e.g. a new extra added to `pyproject.toml.jinja`), CI runs `uv sync --frozen` which fails against a stale lockfile. Run `uv lock` locally and commit the refreshed `uv.lock` alongside accepting the copier-update PR.
|
|
194
|
+
|
|
195
|
+
## Links
|
|
196
|
+
|
|
197
|
+
- [Documentation](https://pvliesdonk.github.io/logodev-mcp/)
|
|
198
|
+
- [llms.txt](https://pvliesdonk.github.io/logodev-mcp/llms.txt)
|
|
199
|
+
- [FastMCP](https://gofastmcp.com)
|
|
200
|
+
- [fastmcp-pvl-core](https://pypi.org/project/fastmcp-pvl-core/)
|
|
201
|
+
|
|
202
|
+
<!-- ===== TEMPLATE-OWNED SECTIONS END ===== -->
|
|
203
|
+
|
|
204
|
+
## Domain configuration
|
|
205
|
+
|
|
206
|
+
<!-- DOMAIN-START -->
|
|
207
|
+
Domain environment variables use the `LOGODEV_MCP_` prefix:
|
|
208
|
+
|
|
209
|
+
| Variable | Default | Required | Description |
|
|
210
|
+
|---|---|---|---|
|
|
211
|
+
| `LOGODEV_MCP_PUBLISHABLE_KEY` | — | Conditional | logo.dev publishable key (`pk_…`). Enables the `get_logo` tool. Omit to hide that tool. |
|
|
212
|
+
| `LOGODEV_MCP_SECRET_KEY` | — | Conditional | logo.dev secret key (`sk_…`). Enables `search_brands`, `describe_company`, and `get_brand`. Omit to hide those tools. |
|
|
213
|
+
|
|
214
|
+
At least one key must be set for any API tool to be registered. Both keys may be set simultaneously to enable all four tools.
|
|
215
|
+
<!-- DOMAIN-END -->
|
|
216
|
+
|
|
217
|
+
## Key design decisions
|
|
218
|
+
|
|
219
|
+
<!-- DOMAIN-START -->
|
|
220
|
+
- **Two-key gating** — `LOGODEV_MCP_PUBLISHABLE_KEY` controls `get_logo`; `LOGODEV_MCP_SECRET_KEY` controls `search_brands`, `describe_company`, and `get_brand`. Tools for a missing key are never registered, not merely guarded at call time.
|
|
221
|
+
- **Domain logic stays FastMCP-free** — `src/logodev_mcp/domain.py` contains the `Service` class and `LogoDevError` with no FastMCP imports; `src/logodev_mcp/tools.py` is the sole FastMCP layer.
|
|
222
|
+
- **Errors surface as strings** — `LogoDevError` raised in domain code is caught in each tool wrapper and returned as a plain text message so the MCP client sees a readable error, not a server exception.
|
|
223
|
+
- **Logo tool returns URL + image** — `get_logo` returns both the CDN URL (text) and the image bytes (image content block) unless `url_only=True`, in which case only the URL string is returned.
|
|
224
|
+
<!-- DOMAIN-END -->
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
logodev_mcp/__init__.py,sha256=-JvuJoTSP6TZQxzjWuKVnA2_F3qVHf39Ay7tpHY6ZzA,104
|
|
2
|
+
logodev_mcp/_server_apps.py,sha256=3HXWRE4gfLZtnFyTPJWpJWViN-IPb2phBHmWItTNSVQ,1131
|
|
3
|
+
logodev_mcp/_server_deps.py,sha256=NsatfQp2I9zSeNrKdcvbtva0huEp8P9K3VofmvXCOBk,1391
|
|
4
|
+
logodev_mcp/cli.py,sha256=1uLiyC1il3EV5dKmdBXgPoIJ3O--6y6RuGZGf9KLEp4,4010
|
|
5
|
+
logodev_mcp/config.py,sha256=ef49KQ4E9ueKOf0yDuUHhCzAh5J6Ka3AJBllqBrrv3c,1588
|
|
6
|
+
logodev_mcp/domain.py,sha256=8T_si_DwTFivySn1_G5i9fq461BUDech2rsV1LAv2Lk,10408
|
|
7
|
+
logodev_mcp/prompts.py,sha256=q0sYDBuDU2gRJgcEkVVTlJi0KtA0CvlR5HGRLwszyBo,578
|
|
8
|
+
logodev_mcp/resources.py,sha256=FQ-POLn8oBanBBQdPHYwSB8p9ne35VTRt7GvzDZUgfU,1213
|
|
9
|
+
logodev_mcp/server.py,sha256=AyON4VwV-PKhPf2OC-uWVxUAfshrRIZj8bYebqVskbs,4091
|
|
10
|
+
logodev_mcp/tools.py,sha256=FFu7e29TQKJ9ydv2qMwdlezdraRSmMJ7AGBy_FZfyLM,6104
|
|
11
|
+
logodev_mcp/static/app.html,sha256=q3VIlwCRy0j3wGZJ_nGDQAQuYYNYwCgreR-QS4aDATk,354
|
|
12
|
+
logodev_mcp/static/icons/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
logodev_mcp-1.0.0.dist-info/METADATA,sha256=ubpix2khiZs5Gmds6mwyDkp7VfPl77Cn0_8GZXGB4w0,12562
|
|
14
|
+
logodev_mcp-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
logodev_mcp-1.0.0.dist-info/entry_points.txt,sha256=XT9UIG8NH19ogPODZ-KoP_OKRtfjc0hNToWNJa1xtfc,53
|
|
16
|
+
logodev_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=XVmiSRB0z51z6Z_qxoEygSz96BAGVRZMIVD8-P_ytT8,1987
|
|
17
|
+
logodev_mcp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Copyright (c) 2026 pvliesdonk
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
TODO: Choose a license for this project before publishing.
|
|
8
|
+
|
|
9
|
+
This file is a placeholder shipped by the fastmcp-server-template
|
|
10
|
+
scaffold. Until you replace it, the project is proprietary and no
|
|
11
|
+
permission is granted to use, copy, modify, merge, publish, distribute,
|
|
12
|
+
sublicense, or sell copies of this software.
|
|
13
|
+
|
|
14
|
+
How to pick and apply a license
|
|
15
|
+
-------------------------------
|
|
16
|
+
|
|
17
|
+
1. Pick one — see https://choosealicense.com/ (MIT, Apache-2.0,
|
|
18
|
+
BSD-3-Clause, GPL-3.0, MPL-2.0, and proprietary are all common).
|
|
19
|
+
|
|
20
|
+
2. Replace this file with the full text of the chosen license.
|
|
21
|
+
|
|
22
|
+
3. Update the FOUR other places the scaffold claims a license:
|
|
23
|
+
|
|
24
|
+
a. pyproject.toml:
|
|
25
|
+
license = "LicenseRef-Proprietary"
|
|
26
|
+
↓
|
|
27
|
+
license = "MIT" # or "Apache-2.0", etc.
|
|
28
|
+
|
|
29
|
+
b. pyproject.toml classifiers list:
|
|
30
|
+
"License :: Other/Proprietary License",
|
|
31
|
+
↓
|
|
32
|
+
"License :: OSI Approved :: MIT License",
|
|
33
|
+
|
|
34
|
+
See https://pypi.org/classifiers/ for the full classifier list.
|
|
35
|
+
|
|
36
|
+
c. packaging/mcpb/manifest.json.in:
|
|
37
|
+
"license": "UNLICENSED"
|
|
38
|
+
↓
|
|
39
|
+
"license": "MIT" # or the SPDX id you chose
|
|
40
|
+
|
|
41
|
+
d. packaging/nfpm.yaml:
|
|
42
|
+
license: "Proprietary"
|
|
43
|
+
↓
|
|
44
|
+
license: "MIT"
|
|
45
|
+
|
|
46
|
+
4. If you never pick one, leave this file as-is and the project remains
|
|
47
|
+
proprietary. Anyone you share the code with still has no license to
|
|
48
|
+
use it unless you explicitly grant rights separately.
|
|
49
|
+
|
|
50
|
+
Caveat: the files above are in _skip_if_exists in the template, so
|
|
51
|
+
future ``copier update`` runs will NOT re-render them — your license
|
|
52
|
+
choice is durable across template bumps. However, pyproject.toml
|
|
53
|
+
itself is NOT skip-if-exists, so if you accept the default on a template
|
|
54
|
+
update, the ``license =`` and classifier lines may flip back to the
|
|
55
|
+
template's defaults. If that happens, revert those two lines.
|