-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp_instance.py
More file actions
133 lines (112 loc) · 5.66 KB
/
Copy pathmcp_instance.py
File metadata and controls
133 lines (112 loc) · 5.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
"""Shared MCPServer factory and default instance for the full MCP surface."""
import os
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from competitor_ads_app import register_competitor_gallery
from creative_app import register_creative_gallery
from media_app import build_media_apps
# ---------------------------------------------------------------------------
# PostHog MCP analytics — created once at module scope, never per request.
# Guards against missing env vars so the app boots fine without PostHog set.
# ---------------------------------------------------------------------------
_POSTHOG_TOKEN = os.environ.get("POSTHOG_PROJECT_TOKEN", "")
_POSTHOG_HOST = os.environ.get("POSTHOG_HOST", "")
posthog_client = None
if _POSTHOG_TOKEN and _POSTHOG_HOST:
from posthog import Posthog
# Tag every event with which deployment produced it. Several deployments
# can run this same code and report into one analytics project, all
# sending the same $mcp_server_name -- without this their tool calls are
# indistinguishable and the dataset collapses into one stream.
#
# BRAND_NAME is the same env var a white-label deploy already sets,
# so this needs no new configuration anywhere.
posthog_client = Posthog(
_POSTHOG_TOKEN,
host=_POSTHOG_HOST,
super_properties={
"deployment": os.environ.get("BRAND_NAME", "").strip() or "self-hosted",
"deployment_url": os.environ.get("APP_URL", "").strip(),
},
)
# Unset means analytics off -- deliberate, not an error, so no nag. It would be
# noise on every self-host start, and in stdio transports stderr chatter is one
# bug away from corrupting the protocol stream.
# Enable DNS rebinding protection in production; disable locally for dev
_dns_protection = os.environ.get("DISABLE_DNS_REBINDING_PROTECTION", "").lower() != "true"
# Allowed hostnames for DNS rebinding protection. Loopback by default; a
# deployment behind a domain adds it via ALLOWED_HOSTS.
_allowed_hosts = ["localhost", "127.0.0.1"]
_extra = os.environ.get("ALLOWED_HOSTS", "")
if _extra:
_allowed_hosts.extend(h.strip() for h in _extra.split(",") if h.strip())
def build_mcp(server_name: str, instructions: str | None = None) -> MCPServer:
"""Build an MCPServer instance.
`instructions` is returned in the MCP initialize response and injected by
clients (Claude, ChatGPT) as session-level context. Without it the model
connects, sees only the category dispatcher tools, and has no idea what sits
behind them -- which is why it used to claim capabilities did not exist and
had to be re-taught in every conversation.
Every surface gets the media-upload App. Extensions are applied at
construction and then frozen, so this is the only place it can be attached;
it is what makes the server advertise io.modelcontextprotocol/ui in the
initialize response, which is the signal a host waits for before it will
fetch and mount a ui:// resource.
"""
return MCPServer(
name=server_name,
instructions=instructions,
extensions=[register_competitor_gallery(register_creative_gallery(build_media_apps()))],
)
def build_http_app(server: MCPServer):
"""The streamable-HTTP ASGI app for a server.
stateless_http and transport_security moved off the constructor in SDK 2.0,
so they belong here rather than in build_mcp. Keeping this in one helper
stops the two surfaces drifting apart on transport settings -- a mismatch
there shows up as a 421 on one endpoint and not the other.
"""
return server.streamable_http_app(
stateless_http=True,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=_dns_protection,
allowed_hosts=_allowed_hosts,
),
)
mcp = build_mcp("Agency Tools")
def mcp_event_properties(request, extra=None):
"""Record which underlying action ran, not just the category it came from.
This server publishes ~11 category dispatchers, not its 3,177 tools, so
every call arrives as tools/call on meta_ads_action or gsc_action with the
real action buried in the arguments. PostHog therefore recorded
"meta_ads_action x66" and nothing about which of the 230 Meta tools anyone
actually used -- which is the only version of the question worth asking.
The action is lifted out into its own property here. Kept defensive: this
runs on every captured event, including ones with no arguments at all, and
an exception in analytics must never surface as a failed tool call.
"""
try:
params = (request or {}).get("params") or {}
arguments = params.get("arguments") or {}
if not isinstance(arguments, dict):
return None
action = str(arguments.get("action") or "").strip()
if not action:
return None
category = str(params.get("name") or "").strip()
props = {"mcp_action": action}
if category:
# Qualified too, because action names are unique per family but the
# pair is what identifies a call when reading a breakdown.
props["mcp_category"] = category
props["mcp_action_full"] = f"{category}:{action}"
return props
except Exception: # noqa: BLE001 - analytics must not break a tool call
return None
# Instrument the full server with PostHog MCP analytics (idempotent, additive).
mcp_analytics = None
if posthog_client is not None:
from posthog.mcp import MCPAnalyticsOptions, instrument
mcp_analytics = instrument(
mcp, posthog_client,
MCPAnalyticsOptions(event_properties=mcp_event_properties),
)