free-image-library 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Surendran B
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: free-image-library
3
+ Version: 0.1.0
4
+ Summary: Free images for AI agents: search millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys.
5
+ Author-email: Surendran B <reachsuren@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://free-image-library.builditwithai.xyz
8
+ Project-URL: Repository, https://github.com/surendranb/free-image-library
9
+ Project-URL: Issues, https://github.com/surendranb/free-image-library/issues
10
+ Keywords: free-images,free-image-library,royalty-free-images,creative-commons-images,stock-images,openverse,image-search,mcp,mcp-server,model-context-protocol,ai-agent,claude,llm,agentic-workflows
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: mcp<3,>=2.0.0
22
+ Requires-Dist: requests>=2.31.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Requires-Dist: pytest-asyncio; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # Free Image Library — Free CC Images & Audio MCP 🖼️
29
+
30
+ [![CI](https://github.com/surendranb/free-image-library/actions/workflows/package-checks.yml/badge.svg)](https://github.com/surendranb/free-image-library/actions)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE)
32
+ [![PyPI](https://img.shields.io/pypi/v/free-image-library.svg)](https://pypi.org/project/free-image-library/)
33
+
34
+ > **Free images for any project: millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys, zero configuration.**
35
+
36
+ The visual sibling of the [Free Music Library](https://github.com/surendranb/music-mcp)
37
+ for the same content-creator audience: the agent finds the image AND the credit
38
+ line in one call, so the attribution never gets lost between copy and paste.
39
+
40
+ ## Why this exists
41
+
42
+ - Models suggest random stock images (license unknown, credit never written).
43
+ This returns license-safe images with the credit the user MUST paste — built
44
+ into the same result.
45
+ - **License intelligence, not just labels**: every row carries a `credit_note`
46
+ stating the actual obligation — CC BY-NC rows are loudly flagged
47
+ non-commercial-only before your client ships them into a paid campaign.
48
+ - **Serendipity**: `image_roulette` picks from deep in the result set, not the
49
+ first hit everyone has seen.
50
+ - Built on [Openverse](https://api.openverse.org) — keyless (anonymous limits:
51
+ 20 requests/min, 200/day, verified; a free registered key raises them via
52
+ `OPENVERSE_API_KEY`). A 5-minute TTL cache makes repeat searches free.
53
+
54
+ ## Tools
55
+
56
+ | Tool | What it does |
57
+ |---|---|
58
+ | `find_images` | Free CC image search with filters (license, aspect_ratio, size, extension) + attribution on every row |
59
+ | `image_roulette` | One random license-safe pick, honest about how it was chosen |
60
+ | `find_audio` | Free CC music / sound effects / field recordings from Openverse's audio index |
61
+ | `skills_list` / `skill_read` | Updatable playbooks: CC license briefs in plain language, error recovery |
62
+
63
+ Plus prompts: `hero-image`, `license-safe-images`.
64
+
65
+ ## Quickstart
66
+
67
+ ```bash
68
+ # 1-Line Universal Installer (auto-configures Claude Desktop, Cursor, Claude Code, VS Code, ...)
69
+ curl -fsSL "https://free-image-library.builditwithai.xyz/install" | bash
70
+
71
+ # Or run directly via your preferred runtime:
72
+ uvx free-image-library
73
+ npx -y free-image-library
74
+ ```
75
+
76
+ ## Example
77
+
78
+ ```
79
+ User: I need a hero image for my climate blog post
80
+
81
+ find_images(query="melting glacier", aspect_ratio="wide", count=3,
82
+ intent="hero image for a climate blog post")
83
+ → images: [{
84
+ title: "Glacier calving", creator: "…", license: "CC BY 2.0",
85
+ image_url: "https://…", width: 2048, height: 1152,
86
+ attribution: "\"Glacier calving\" image by … (flickr), licensed CC BY 2.0 (…)",
87
+ credit_note: "Credit REQUIRED — paste the attribution verbatim." }]
88
+ ```
89
+
90
+ ## Telemetry & privacy
91
+
92
+ Anonymous usage telemetry (no PII, no queries, no paths) via the fleet
93
+ standard (schema v2, dual-endpoint fallback). Opt out any time:
94
+ `FREE_IMAGE_LIBRARY_TELEMETRY=false` or `DO_NOT_TRACK=1`.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ uv venv && uv pip install -e ".[dev]"
100
+ DO_NOT_TRACK=1 .venv/bin/python -m pytest tests/ -q # unit + live + e2e
101
+ ```
102
+
103
+ Live tests hit the real Openverse API; they skip themselves when offline or
104
+ rate-limited.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,81 @@
1
+ # Free Image Library — Free CC Images & Audio MCP 🖼️
2
+
3
+ [![CI](https://github.com/surendranb/free-image-library/actions/workflows/package-checks.yml/badge.svg)](https://github.com/surendranb/free-image-library/actions)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE)
5
+ [![PyPI](https://img.shields.io/pypi/v/free-image-library.svg)](https://pypi.org/project/free-image-library/)
6
+
7
+ > **Free images for any project: millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys, zero configuration.**
8
+
9
+ The visual sibling of the [Free Music Library](https://github.com/surendranb/music-mcp)
10
+ for the same content-creator audience: the agent finds the image AND the credit
11
+ line in one call, so the attribution never gets lost between copy and paste.
12
+
13
+ ## Why this exists
14
+
15
+ - Models suggest random stock images (license unknown, credit never written).
16
+ This returns license-safe images with the credit the user MUST paste — built
17
+ into the same result.
18
+ - **License intelligence, not just labels**: every row carries a `credit_note`
19
+ stating the actual obligation — CC BY-NC rows are loudly flagged
20
+ non-commercial-only before your client ships them into a paid campaign.
21
+ - **Serendipity**: `image_roulette` picks from deep in the result set, not the
22
+ first hit everyone has seen.
23
+ - Built on [Openverse](https://api.openverse.org) — keyless (anonymous limits:
24
+ 20 requests/min, 200/day, verified; a free registered key raises them via
25
+ `OPENVERSE_API_KEY`). A 5-minute TTL cache makes repeat searches free.
26
+
27
+ ## Tools
28
+
29
+ | Tool | What it does |
30
+ |---|---|
31
+ | `find_images` | Free CC image search with filters (license, aspect_ratio, size, extension) + attribution on every row |
32
+ | `image_roulette` | One random license-safe pick, honest about how it was chosen |
33
+ | `find_audio` | Free CC music / sound effects / field recordings from Openverse's audio index |
34
+ | `skills_list` / `skill_read` | Updatable playbooks: CC license briefs in plain language, error recovery |
35
+
36
+ Plus prompts: `hero-image`, `license-safe-images`.
37
+
38
+ ## Quickstart
39
+
40
+ ```bash
41
+ # 1-Line Universal Installer (auto-configures Claude Desktop, Cursor, Claude Code, VS Code, ...)
42
+ curl -fsSL "https://free-image-library.builditwithai.xyz/install" | bash
43
+
44
+ # Or run directly via your preferred runtime:
45
+ uvx free-image-library
46
+ npx -y free-image-library
47
+ ```
48
+
49
+ ## Example
50
+
51
+ ```
52
+ User: I need a hero image for my climate blog post
53
+
54
+ find_images(query="melting glacier", aspect_ratio="wide", count=3,
55
+ intent="hero image for a climate blog post")
56
+ → images: [{
57
+ title: "Glacier calving", creator: "…", license: "CC BY 2.0",
58
+ image_url: "https://…", width: 2048, height: 1152,
59
+ attribution: "\"Glacier calving\" image by … (flickr), licensed CC BY 2.0 (…)",
60
+ credit_note: "Credit REQUIRED — paste the attribution verbatim." }]
61
+ ```
62
+
63
+ ## Telemetry & privacy
64
+
65
+ Anonymous usage telemetry (no PII, no queries, no paths) via the fleet
66
+ standard (schema v2, dual-endpoint fallback). Opt out any time:
67
+ `FREE_IMAGE_LIBRARY_TELEMETRY=false` or `DO_NOT_TRACK=1`.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ uv venv && uv pip install -e ".[dev]"
73
+ DO_NOT_TRACK=1 .venv/bin/python -m pytest tests/ -q # unit + live + e2e
74
+ ```
75
+
76
+ Live tests hit the real Openverse API; they skip themselves when offline or
77
+ rate-limited.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ """Attribution builders — the instant-credit pattern from music-mcp.
4
+
5
+ Every result row can be turned into ready-to-paste credit lines in three
6
+ formats. CC BY / BY-SA require credit; CC0/PDM don't (but crediting is good
7
+ practice and we say so rather than silently dropping it).
8
+ """
9
+
10
+ _LICENSE_NAMES = {
11
+ "CC0": "CC0 1.0 (public domain)",
12
+ "PDM": "Public Domain Mark",
13
+ "BY": "CC BY",
14
+ "BY-SA": "CC BY-SA",
15
+ "BY-NC": "CC BY-NC",
16
+ "BY-NC-SA": "CC BY-NC-SA",
17
+ "BY-ND": "CC BY-ND",
18
+ "BY-NC-ND": "CC BY-NC-ND",
19
+ }
20
+
21
+
22
+ def _license_label(row):
23
+ lic = (row.get("license") or "").upper()
24
+ version = row.get("license_version")
25
+ name = _LICENSE_NAMES.get(lic, lic or "unknown license")
26
+ return f"{name} {version}".strip() if version else name
27
+
28
+
29
+ def _creator(row):
30
+ creator = (row.get("creator") or "").strip()
31
+ if not creator:
32
+ return None, None
33
+ source = row.get("source") or "the source"
34
+ url = row.get("foreign_landing_url")
35
+ return creator, f'by <a href="{url}">{creator}</a> ({source})' if url \
36
+ else f"by {creator} ({source})"
37
+
38
+
39
+ def credit_lines(row, media_noun="image"):
40
+ """(plaintext, markdown, html) attribution for one result row."""
41
+ title = row.get("title") or "(untitled)"
42
+ creator, creator_html = _creator(row)
43
+ creator_plain = creator or "unknown creator"
44
+ source = row.get("source") or "the source"
45
+ license_label = _license_label(row)
46
+ license_url = row.get("license_url")
47
+ landing = row.get("foreign_landing_url") or row.get("image_url") \
48
+ or row.get("audio_url") or ""
49
+
50
+ lic_plain = f"{license_label} ({license_url})" if license_url else license_label
51
+ lic_md = f"[{license_label}]({license_url})" if license_url else license_label
52
+ lic_html = (f'<a href="{license_url}">{license_label}</a>'
53
+ if license_url else license_label)
54
+
55
+ title_md = f"[{title}]({landing})" if landing else title
56
+ title_html = f'<a href="{landing}">{title}</a>' if landing else title
57
+
58
+ plain = (f'"{title}" {media_noun} by {creator_plain} ({source}), '
59
+ f"licensed {lic_plain}")
60
+ if creator:
61
+ markdown = (f'*"{title}"* {media_noun} '
62
+ f"{creator_md(creator, landing, source)} — {lic_md}")
63
+ else:
64
+ markdown = f'*"{title}"* {media_noun} from {source} — {lic_md}'
65
+ html = (f"{title_html} {media_noun} {creator_html} — {lic_html}")
66
+
67
+ return plain, markdown, html
68
+
69
+
70
+ def creator_md(creator, landing, source):
71
+ if not creator:
72
+ return f"from {source}"
73
+ return f"by [{creator}]({landing}) ({source})" if landing else \
74
+ f"by {creator} ({source})"
75
+
76
+
77
+ def credit_note(row):
78
+ """One-line obligation note: what credit this license actually requires."""
79
+ lic = (row.get("license") or "").upper()
80
+ if lic in ("CC0", "PDM"):
81
+ return ("No credit required — but including one is good practice "
82
+ "and helps the creator.")
83
+ if lic in ("BY-NC", "BY-NC-SA", "BY-NC-ND"):
84
+ return ("Credit REQUIRED and commercial use NOT allowed — verify the "
85
+ "use is non-commercial before shipping.")
86
+ if lic:
87
+ return "Credit REQUIRED — paste the attribution verbatim."
88
+ return "License unknown — do not use without checking the source page."
@@ -0,0 +1,202 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ """Openverse API client — CC-licensed images and audio for AI agents.
4
+
5
+ Openverse (WordPress) is keyless: anonymous use is rate-limited to 20/min
6
+ burst and 200/day sustained (verified 2026-08-22), which is plenty for a
7
+ discovery tool with the TTL cache below. A registered key can be supplied
8
+ later via OPENVERSE_API_KEY for higher limits.
9
+ """
10
+
11
+ import os
12
+ import time
13
+ import threading
14
+
15
+ import requests
16
+
17
+ BASE = "https://api.openverse.org/v1"
18
+ USER_AGENT = "free-image-library/0.1.0 (MCP server; +https://github.com/surendranb/free-image-library)"
19
+ TIMEOUT = 10.0
20
+ CACHE_TTL = 300 # 5 min: identical searches within a session cost nothing
21
+
22
+ LICENSES = ("all", "cc", "cc0", "pdm", "by", "by-sa", "by-nc", "by-nc-sa",
23
+ "by-nd", "by-nc-nd")
24
+ ASPECTS = ("wide", "square", "tall")
25
+ SIZES = ("small", "medium", "large")
26
+
27
+
28
+ class OpenverseError(Exception):
29
+ """Upstream failure with a model-facing tag."""
30
+
31
+ def __init__(self, message, rate_limited=False):
32
+ super().__init__(message)
33
+ self.rate_limited = rate_limited
34
+
35
+
36
+ _cache_lock = threading.Lock()
37
+ _cache = {}
38
+
39
+
40
+ def _cached(key):
41
+ now = time.time()
42
+ with _cache_lock:
43
+ hit = _cache.get(key)
44
+ if hit and hit[0] > now:
45
+ return hit[1]
46
+ return None
47
+
48
+
49
+ def _store(key, value):
50
+ with _cache_lock:
51
+ if len(_cache) > 128: # bounded: discovery tool, not a mirror
52
+ _cache.clear()
53
+ _cache[key] = (time.time() + CACHE_TTL, value)
54
+
55
+
56
+ def _headers():
57
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
58
+ key = os.getenv("OPENVERSE_API_KEY") # optional: higher rate limits
59
+ if key:
60
+ headers["Authorization"] = f"Bearer {key}"
61
+ return headers
62
+
63
+
64
+ def _get(path, params):
65
+ cache_key = f"{path}?{sorted((params or {}).items())}"
66
+ hit = _cached(cache_key)
67
+ if hit is not None:
68
+ return hit
69
+ try:
70
+ resp = requests.get(f"{BASE}{path}", params=params or {},
71
+ headers=_headers(), timeout=TIMEOUT)
72
+ except requests.Timeout as e:
73
+ raise OpenverseError("Openverse request timed out "
74
+ "[TRANSIENT: retry once]") from e
75
+ except requests.RequestException as e:
76
+ raise OpenverseError(f"Openverse request failed: {e} "
77
+ "[TRANSIENT: retry once]") from e
78
+ if resp.status_code == 429:
79
+ raise OpenverseError(
80
+ "Openverse anonymous rate limit hit (20/min, 200/day). Wait a "
81
+ "minute before the next call; set OPENVERSE_API_KEY for higher "
82
+ "limits [ENVIRONMENT_FIXABLE: wait or register a key at "
83
+ "api.openverse.org]", rate_limited=True)
84
+ if resp.status_code == 401:
85
+ raise OpenverseError("Openverse rejected OPENVERSE_API_KEY "
86
+ "[ENVIRONMENT_FIXABLE: unset it to use anonymous "
87
+ "access, or fix the key]")
88
+ if resp.status_code >= 400:
89
+ raise OpenverseError(f"Openverse returned HTTP {resp.status_code} "
90
+ "[INPUT_FIXABLE: check the arguments, e.g. an "
91
+ "unknown license or aspect value]")
92
+ try:
93
+ data = resp.json()
94
+ except ValueError as e:
95
+ raise OpenverseError("Openverse returned a non-JSON body "
96
+ "[TRANSIENT]") from e
97
+ _store(cache_key, data)
98
+ return data
99
+
100
+
101
+ def _validate(license, aspect_ratio, size, extension):
102
+ if license not in LICENSES:
103
+ raise ValueError(f"Unknown license {license!r}. Candidates: "
104
+ f"{', '.join(LICENSES)}. [INPUT_FIXABLE]")
105
+ if aspect_ratio is not None and aspect_ratio not in ASPECTS:
106
+ raise ValueError(f"Unknown aspect_ratio {aspect_ratio!r}. Candidates: "
107
+ f"{', '.join(ASPECTS)}. [INPUT_FIXABLE]")
108
+ if size is not None and size not in SIZES:
109
+ raise ValueError(f"Unknown size {size!r}. Candidates: "
110
+ f"{', '.join(SIZES)}. [INPUT_FIXABLE]")
111
+ if extension is not None and extension not in ("jpg", "png", "gif", "svg"):
112
+ raise ValueError(f"Unknown extension {extension!r}. Candidates: "
113
+ "jpg, png, gif, svg. [INPUT_FIXABLE]")
114
+
115
+
116
+ def _params(query, license, aspect_ratio, size, extension, page, page_size):
117
+ params = {"q": query, "page_size": page_size, "page": page,
118
+ "mature": "false"}
119
+ if license == "cc":
120
+ params["license_type"] = "all-cc"
121
+ elif license not in ("all", None):
122
+ params["license"] = license
123
+ if aspect_ratio:
124
+ params["aspect_ratio"] = aspect_ratio
125
+ if size:
126
+ params["size"] = size
127
+ if extension:
128
+ params["extension"] = extension
129
+ return params
130
+
131
+
132
+ def _thumb(url):
133
+ """Openverse thumbnails are proxied and reliably hotlinkable."""
134
+ if not url:
135
+ return None
136
+ return f"https://api.openverse.org/v1/images/{url}" if not str(url).startswith("http") else url
137
+
138
+
139
+ def _image_row(result):
140
+ return {
141
+ "id": result.get("id"),
142
+ "title": result.get("title") or "(untitled)",
143
+ "creator": result.get("creator"),
144
+ "license": (result.get("license") or "").upper() or None,
145
+ "license_version": result.get("license_version"),
146
+ "license_url": result.get("license_url"),
147
+ "source": result.get("source"),
148
+ "image_url": result.get("url"),
149
+ "thumbnail": result.get("thumbnail"),
150
+ "foreign_landing_url": result.get("foreign_landing_url"),
151
+ "width": result.get("width"),
152
+ "height": result.get("height"),
153
+ "tags": [t.get("name") for t in (result.get("tags") or [])
154
+ if t.get("name")][:8],
155
+ }
156
+
157
+
158
+ def _audio_row(result):
159
+ return {
160
+ "id": result.get("id"),
161
+ "title": result.get("title") or "(untitled)",
162
+ "creator": result.get("creator"),
163
+ "license": (result.get("license") or "").upper() or None,
164
+ "license_version": result.get("license_version"),
165
+ "license_url": result.get("license_url"),
166
+ "source": result.get("source"),
167
+ "audio_url": (result.get("url")
168
+ or ((result.get("audio_files") or [{}])[0].get("url"))),
169
+ "duration_s": result.get("duration"),
170
+ "foreign_landing_url": result.get("foreign_landing_url"),
171
+ }
172
+
173
+
174
+ def search_images(query, license="cc", aspect_ratio=None, size=None,
175
+ extension=None, page=1, page_size=10):
176
+ """CC image search. Returns (rows, result_count)."""
177
+ _validate(license, aspect_ratio, size, extension)
178
+ data = _get("/images/", _params(query, license, aspect_ratio, size,
179
+ extension, page, page_size))
180
+ return ([_image_row(r) for r in data.get("results", [])],
181
+ data.get("result_count", 0))
182
+
183
+
184
+ def get_image(image_id):
185
+ """One image record by id (for attribution formatting)."""
186
+ data = _get(f"/images/{image_id}/", {})
187
+ return _image_row(data)
188
+
189
+
190
+ def search_audio(query, license="cc", page=1, page_size=8):
191
+ """CC audio search (music, sound effects, field recordings)."""
192
+ if license not in LICENSES:
193
+ raise ValueError(f"Unknown license {license!r}. Candidates: "
194
+ f"{', '.join(LICENSES)}. [INPUT_FIXABLE]")
195
+ params = {"q": query, "page_size": page_size, "page": page, "mature": "false"}
196
+ if license == "cc":
197
+ params["license_type"] = "all-cc"
198
+ elif license not in ("all", None):
199
+ params["license"] = license
200
+ data = _get("/audio/", params)
201
+ return ([_audio_row(r) for r in data.get("results", [])],
202
+ data.get("result_count", 0))