Skip to main content

Disclosed sponsored slots for MCP servers and AI agent tools. Ships data, never directives. Fail-open by design.

Project description

lulu-ads

The monetization layer for the agent economy.

Monetize your MCP server or agent tool with one labeled sponsored line.

PyPI npm License: MIT Backend Publisher beta Rev share

Quickstart · Integrations · Guarantees · API contract · Hosted docs · Blog · Become a publisher

Lulu, the Lulu Ads narwhal mascot, celebrating on a Tel Aviv billboard — the agent economy has a monetization layer now

70% to publishers · CPA only · 300ms fail-open · 0 prompt injections, by design

Lulu Ads attaches a disclosed, labeled data field to your tool's own result. The host model — Claude, Cursor, any agent — decides on its own judgment whether it's relevant enough to surface. We never instruct it to.

What the SDK ships (a data field) What the host renders (its choice)
{
  "sponsored": {
    "label": "Sponsored",
    "text": "Direct flights TLV–BKK from $412",
    "url": "https://ads.getlulu.dev/c/9f2a1c"
  }
}

Sponsored — Direct flights TLV–BKK from $412 ads.getlulu.dev/c/9f2a1c

Zero-friction start — add the MCP server and let your agent do the rest:

claude mcp add --transport http lulu-ads https://ads.getlulu.dev/mcp

monetize my server

It'll fetch the right integration guide for your stack, register a publisher (with your consent), wire up the one-liner, and verify a slot went live.

If it renders and gets clicked, you earn 70% on CPA. If it doesn't — nobody pays, nothing breaks.

No prompt injection — we ship a data field; the host decides.

Quickstart

Python

pip install lulu-ads
# or: uv add lulu-ads
# or: poetry add lulu-ads
from lulu_ads import LuluAds
ads = LuluAds(publisher_id="pub_123", api_key="lk_...")

result = search_flights("TLV", "BKK", dates)
result["sponsored"] = await ads.sponsored_slot(
    context={"tool": "search_flights", "category": "travel.flights"},
)
return result

FastMCP servers get it in one line — credentials come from the environment:

export LULU_ADS_PUBLISHER_ID=pub_123
export LULU_ADS_API_KEY=lk_...
mcp.add_middleware(LuluAdsMiddleware())

TypeScript

npm install lulu-ads
# or: pnpm add lulu-ads
# or: yarn add lulu-ads
# or: bun add lulu-ads
import { LuluAds } from "lulu-ads";
const ads = new LuluAds({ publisherId: "pub_123", apiKey: "lk_..." });
result.sponsored = await ads.sponsoredSlot({ context: { tool: "search_flights" } });

No publisher ID yet? See docs/quickstart.md — three ways to get one, none of them gated on the others.

Framework integrations

Stack One-liner Docs
FastMCP (Python) mcp.add_middleware(LuluAdsMiddleware())
LangChain / LangGraph (Python) middleware=[LuluAdsAgentMiddleware()]
CrewAI (Python) lulu_crewai.install()
MCP TS SDK withLuluAds(server)
Runtime owners (chat bots, WhatsApp/Telegram agents) model_output + format_suffix(sponsored)
Any other runtime / language sponsored_slot(context) over the raw contract

Widget rendering (MCP Apps UI)

The plain sponsored field always ships and always works — some hosts render it as a card purely on the model's own judgment, no instruction anywhere. For hosts that support the MCP Apps extension (io.modelcontextprotocol/ui), you can additionally register an actual rendered widget instead of relying on that judgment call:

from fastmcp import FastMCP
from lulu_ads.widget import register_sponsored_widget

mcp = FastMCP("my-server")
sponsored_app = register_sponsored_widget(
    mcp,
    endpoint_url="https://my-server.example.com/mcp",  # your public MCP connector URL
    text="Save 15% at checkout",
    url="https://example.com/deal",
)

@mcp.tool(app=sponsored_app)
def search(...): ...

Same helper, official TS SDK, for MCP servers built in Node instead of Python:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSponsoredWidget } from "lulu-ads/widget";

const server = new McpServer({ name: "my-server", version: "1.0.0" });
const appMeta = registerSponsoredWidget(server, {
  endpointUrl: "https://my-server.example.com/mcp", // your public MCP connector URL
  text: "Save 15% at checkout",
  url: "https://example.com/deal",
});

server.registerTool("search", { ...appMeta }, handler);

Ships a floating, rounded, gradient card (same visual system as getlulu.dev) with a disclosed Sponsored label — still just markup, never a directive. Two host-specific quirks this handles for you: Claude requires an undocumented _meta.ui.domain value derived from your endpoint URL (self-computed here, not a credential), and the widget must send a ui/notifications/initialized handshake on load or Claude keeps the iframe hidden. Verified live against production (dali.getlulu.dev/mcp, ext-apps#671), current as of 2026-07-19 — Claude's own rendering of MCP Apps widgets was broken platform-wide before that fix landed, so treat any "should render" claim (including this one, elsewhere) as unverified until you've checked it live in your own host.

Card content is fixed at registration time — a house-ad tier, same as the plain field's house-fill path, not re-rendered per call yet.

CLI rendering

Terminals have no widget surface — the model's own text is the only output there is, and it's genuinely the model's judgment call whether to mention the disclosed line at all (never forced, ever — see Guarantees). LuluAdsMiddleware / withLuluAds detect known CLI clients via the MCP clientInfo.name sent at initialize (currently: claude-code, verified live) and, when connected from one, append a bordered plain-text card to content[] in addition to the plain field — still just data, still zero instruction to the model, just formatted so it reads as a distinct block instead of a plain sentence if the model does choose to relay it:

┌─ Sponsored ────────────────────────────────────┐
│ Search 700+ airlines in one place — Kiwi.com   │
│ finds routes other search engines miss.        │
└────────────────────────────────────────────────┘
→ https://ads.getlulu.dev/c/9f2a1c

Known limitation, disclosed here rather than glossed over: some MCP clients don't forward every content[] block to the model when structuredContent is also present on the same result (see anthropics/claude-code#55677) — an open client-side bug, not something this SDK can work around without either dropping structuredContent.sponsored (which would break well-behaved clients to chase one buggy one) or prompt injection (off the table, permanently). Until that's fixed upstream, treat the CLI card as "renders when the host actually forwards it," not a guarantee — same verify-in-your-own-host caveat as the widget path above.

Guarantee How
A tool call can never break because of ads every failure path returns None/null; hard 300ms wall-clock timeout
Always disclosed label: "Sponsored" is set by the SDK, never sourced from the response body
No prompt injection, ever we ship a data field; there is no display instruction anywhere in the contract
No PII leaves your server context is filtered against an allowlist client-side, before any request is built
Quality-gated every creative passes Dali scoring (≥70) before it can fill a slot
Intent, not identity targeting uses this call's stated context only — no user profiles, no cross-session ID
Misconfigured? Still safe missing credentials → client is inert, returns None/null, zero network calls

Why not just…

…tell the model to mention a sponsor in its reply? Display instructions get MCP servers delisted by registries that scan for injected directives. We ship a plain data object — label, text, url — with no field, anywhere in the contract, that tells a model how to render or phrase anything.

…count impressions and charge per view? An "impression" only exists if a model actually rendered it, and that's unverifiable from the server side — easy to game, hard to audit. We charge CPA only, on a click that redeems a signed, server-verified token. Payment maps to a real user action, not a claim.

…scan the conversation to target better? Reading transcripts to target ads is a privacy trap: everything a user says becomes ad-targeting data. We accept six allowlisted context keys — tool, category, query, route, locale, country — stated intent for this call only. No transcripts, no profiles, no PII fields exist in the schema.

How it works

tool call
   │
   ▼
your tool's own result
   │
   ▼
POST /slot  (300ms cap, allowlisted context only)
   │
   ▼
labeled data field  { label: "Sponsored", text, url }   ← attached, never injected
   │
   ▼
host / model judgment   →   renders it, or doesn't — not our call
   │  user clicks
   ▼
GET /c/{token}   →   signed redirect, click recorded
   │
   ▼
advertiser's affiliate rails   →   POST /postback on conversion
   │
   ▼
70% publisher / 30% Lulu, on the ledger. Earnings accrue to your balance from the first audited conversion — cash out from $100.

Full wire-level detail: docs/contract.md.


Docs: https://getlulu.dev/docs · Quickstart · API contract · Integrations · Publisher signup · Quality gate: Dali · MIT

Changelog

  • 0.2.0register_sponsored_widget() (Python: lulu_ads.widget, now also TypeScript: lulu-ads/widget, official MCP SDK): registers a real rendered MCP Apps UI sponsored card on your server (not just the plain JSON field), handling Claude's undocumented iframe-domain requirement and the ui/notifications/initialized handshake for you. Generalizes the fix verified live on dali.getlulu.dev/mcp against ext-apps#671. Both SDKs produce byte-identical _meta.ui.domain values for the same endpoint URL.
  • 0.1.1 — persistent HTTP clients in the Python SDK (per-call client construction could burn the entire slot budget on CPU-constrained containers; clients are now created once per LuluAds instance and reused with keep-alive). Fail-open behavior unchanged.
  • 0.1.0 — initial release: Python + TypeScript clients, FastMCP / LangChain / LangGraph / CrewAI / MCP-TS adapters, suffix helpers, MCP concierge onboarding.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lulu_ads-0.2.5.tar.gz (19.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lulu_ads-0.2.5-py3-none-any.whl (18.6 kB view details)

Uploaded Python 3

File details

Details for the file lulu_ads-0.2.5.tar.gz.

File metadata

  • Download URL: lulu_ads-0.2.5.tar.gz
  • Upload date:
  • Size: 19.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for lulu_ads-0.2.5.tar.gz
Algorithm Hash digest
SHA256 778ffcd301bb203d87e22f22a1b75a50488d4c8fddd9be84ea62d7b9a4c9374a
MD5 7e76cb8a587af75c2db55d0c1af30cac
BLAKE2b-256 db5db119646b5b0c8f75a82919c3a1e1fdf293d2ea9b26751bb58fb0b4c52b02

See more details on using hashes here.

File details

Details for the file lulu_ads-0.2.5-py3-none-any.whl.

File metadata

  • Download URL: lulu_ads-0.2.5-py3-none-any.whl
  • Upload date:
  • Size: 18.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for lulu_ads-0.2.5-py3-none-any.whl
Algorithm Hash digest
SHA256 1a72b28e1f0ce780a9bdb1dc1aed8a9cbdb02887fb270349bd2d5a82420c08e4
MD5 fe3db3eee78b9084f32aa6daa75ceba3
BLAKE2b-256 47ba3708085a7415643800c94a4f4dc8ec548bfd4e3bb372fa3d8dcc7183f908

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page