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.
@@ -0,0 +1,6 @@
1
+ """Logo.dev MCP.
2
+
3
+ Look up company logos and brand data via the logo.dev API.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -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}"
@@ -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
+ [![CI](https://github.com/pvliesdonk/logodev-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/pvliesdonk/logodev-mcp/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/pvliesdonk/logodev-mcp/graph/badge.svg)](https://codecov.io/gh/pvliesdonk/logodev-mcp) [![PyPI](https://img.shields.io/pypi/v/logodev-mcp)](https://pypi.org/project/logodev-mcp/) [![Python](https://img.shields.io/pypi/pyversions/logodev-mcp)](https://pypi.org/project/logodev-mcp/) [![License](https://img.shields.io/github/license/pvliesdonk/logodev-mcp)](LICENSE) [![Docker](https://img.shields.io/github/v/release/pvliesdonk/logodev-mcp?label=ghcr.io&logo=docker)](https://github.com/pvliesdonk/logodev-mcp/pkgs/container/logodev-mcp) [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://pvliesdonk.github.io/logodev-mcp/) [![llms.txt](https://img.shields.io/badge/llms.txt-available-brightgreen)](https://pvliesdonk.github.io/logodev-mcp/llms.txt) [![Template](https://img.shields.io/badge/dynamic/yaml?url=https://raw.githubusercontent.com/pvliesdonk/logodev-mcp/main/.copier-answers.yml&query=%24._commit&label=template)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ logodev-mcp = logodev_mcp.cli:main
@@ -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.