opensponsorship-os-admin-mcp
v1.11.0
Published
MCP server for OpenSponsorship admin API (browser sign-in + cached JWT)
Maintainers
Readme
OpenSponsorship OS Admin MCP
Model Context Protocol server for calling opensponsorship-admin APIs from Claude Code, Cursor, or any MCP client — without pasting JWTs into chat.
Tools
create_hubspot_draft
Creates a draft HubSpot CMS page or blog post via the OpenSponsorship admin API. Supports three destination types:
| postDraftDestination | HubSpot API used | Slug behaviour |
|---|---|---|
| landing | Landing Pages API | Auto-prefixed with learn-more/ |
| blog | Blog Posts API | As provided (requires blogContentGroupId) |
| custom | Site or Landing Pages API (set via pageKind) | No automatic prefix |
If postDraftDestination is omitted, the tool returns a clarifying question for the agent to surface to the user before proceeding — it will not guess.
Parameters
| Parameter | Required | Description |
|---|---|---|
| postDraftDestination | Yes (or omit to be prompted) | landing, blog, or custom |
| name | Yes | Page/post name in HubSpot |
| htmlBody | Yes | HTML body (rich text module for pages; postBody for blog) |
| slug | No | URL slug — derived from name if omitted |
| htmlTitle | No | Browser tab title — defaults to name |
| metaDescription | No | SEO meta description |
| templatePath | No | HubSpot template path (landing/website only); overrides useBlankCanvas |
| useBlankCanvas | No | Set true only when the user explicitly asked for a custom-designed / blank-canvas page (uses blank-canvas.html). Omit for server defaults. Ignored for blog posts. |
| pageKind | No | website or landing — only used when postDraftDestination is custom |
| blogContentGroupId | Yes (blog only) | HubSpot blog content group ID |
| featuredImage | No (blog only) | Featured thumbnail URL — use upload_hubspot_file url |
| featuredImageAltText | No (blog only) | Alt text for featured image |
upload_hubspot_file
Upload a local image/file to HubSpot file manager. Returns url for htmlBody / featuredImage.
| Parameter | Required | Description |
|---|---|---|
| filePath | One of path/base64 | Local file path |
| fileBase64 | One of path/base64 | Base64-encoded bytes |
| fileName | If base64 | e.g. hero.png |
| folderPath | No | Default /blog-agent-uploads |
| access | No | Default PUBLIC_NOT_INDEXABLE |
Requires HubSpot private app files scope.
update_hubspot_post
Update an existing blog post. Supports featuredImage and featuredImageAltText.
| Parameter | Required | Description |
|---|---|---|
| postId | Yes | HubSpot blog post ID |
| featuredImage | No | Featured/hero thumbnail URL |
| featuredImageAltText | No | Alt text for featured image |
| (other fields) | No | name, htmlTitle, metaDescription, postBody, slug |
get_hubspot_post_analytics
Returns HubSpot performance metrics for one blog post over a date range (views, visitors, bounce, submissions, etc.).
| Parameter | Required | Description |
|---|---|---|
| postId | Yes | HubSpot blog post object ID |
| startDate | Yes | Inclusive start (YYYY-MM-DD) |
| endDate | Yes | Inclusive end (YYYY-MM-DD), must be ≥ startDate |
| granularity | No | daily, weekly, monthly, or total (default total) |
get_hubspot_page_analytics
Same shape for site or landing pages. pageKind must match update_hubspot_page (website | landing).
| Parameter | Required | Description |
|---|---|---|
| pageId | Yes | HubSpot page object ID |
| pageKind | Yes | website or landing |
| startDate | Yes | Inclusive start (YYYY-MM-DD) |
| endDate | Yes | Inclusive end (YYYY-MM-DD), must be ≥ startDate |
| granularity | No | daily, weekly, monthly, or total (default total) |
HubSpot scopes: analytics requires business-intelligence on the portal private app (in addition to content for CMS preflight). Add read-content / read-analytics-data if your HubSpot UI lists them.
create_hubspot_email_draft — Phase 1 + Phase 2 (see hs_marketing_email_draft_brief.md)
Creates a HubSpot Marketing Email draft via POST /marketing/v3/emails/. Draft only — never sends or publishes.
Fragility caveat (still applies): despite marketing-email being grayed out/Pro-gated in our private app's scope picker (Marketing Hub Starter), the call succeeds using only the content scope. This looks like a HubSpot scope-enforcement inconsistency rather than an officially documented capability — treat it as fragile, HubSpot could start rejecting it without notice. If you get a 403/MISSING_SCOPES, report back immediately rather than retrying.
htmlBody, previewText, and sections all use undocumented (empirically verified against a real production portal) HubSpot widget shapes — see the doc comments above createDraftEmail/buildSectionsContent in app/services/hubspot/index.js for the full reverse-engineering notes.
| Parameter | Required | Description |
|---|---|---|
| name | Yes | Email name shown in the HubSpot email dashboard |
| subject | No | Subject line |
| previewText | No | Inbox preview text (rendered via a dedicated widget, not a top-level field) |
| fromName | No | Sender display name |
| replyTo | No | Reply-to address |
| sections | No | Preferred. Array of section specs that composes a real multi-column, multi-section layout out of independently-editable widgets (real Text/Image modules, not one flattened image). Wins over htmlBody if both given. |
| htmlBody | No | Legacy single-block mode: HTML for one full-width editable rich-text body module. Ignored if sections is given. |
| templatePath | No | HubSpot template path (defaults to @hubspot/email/dnd/start_from_scratch.html) |
sections — composable layout builder (Phase 2)
Each entry is { type, ...type-specific fields }. Four types, all implemented in buildSectionsContent (app/services/hubspot/index.js):
| type | Shape | Use for |
|---|---|---|
| textImageRow | { backgroundColor?, textHtml, imageUrl?, imageAlt?, imageLink?, imageWidth?, imageHeight?, imagePosition?: "left"\|"right" (default "right"), textWidth?: 1-11 (default 7) } | A 2-column text+image row (e.g. hero). Full-width text-only if imageUrl omitted. |
| columns | { backgroundColor?, columns: [{ width?: 1-12, html?, imageUrl?, imageAlt?, imageLink?, imageWidth?, imageHeight? }] } | Arbitrary N independent columns, e.g. side-by-side profile/quote cards each with their own image+text. |
| mediaGrid | { backgroundColor?, columns?: number (default 3), captionColor?, items: [{ imageUrl, imageAlt?, imageWidth?, imageHeight?, caption?, captionHtml?, link? }] } | Photo/category grid; auto-wraps into multiple rows if items.length > columns. |
| richText | { backgroundColor?, html } | Generic full-width block — headings, bullet/stat banners, anything else. |
Implementation notes learned the hard way (see the brief's Phase 2 result for full detail):
- Text/headings/bullets/buttons all go in
rich_textwidgets — a styled<a>renders as a real button. - Images must use HubSpot's stock Image module (a portal-specific
module_id, discovered by inspecting a real image widget), not an<img>tag inside arich_textwidget's HTML — inline<img>is stored by the API but silently fails to render in the drag-and-drop editor canvas. - Only pass
imageUrlfor real, dedicated photos. Don't reuse another email's flattened/composite graphic (one with its own baked-in text/logo) as a generic stand-in image — it renders as an obviously-wrong mini-thumbnail. If no real matching photo exists, omitimageUrl/userichTextinstead; a correct text-only section beats a misleading fake photo.
On failure, the tool returns httpStatus and hubspotErrorBody (HubSpot's raw error JSON) so you can paste the exact error back for the go/no-go decision.
update_hubspot_email_draft — Phase 3 (see hs_marketing_email_draft_brief.md)
Revises an existing Marketing Email draft in place via PATCH /marketing/v3/emails/{emailId}. Use this instead of create_hubspot_email_draft when iterating on feedback for a draft that already exists — it does not create a duplicate. Same fragility caveat and draft-only posture as the create tool.
| Parameter | Required | Description |
|---|---|---|
| emailId | Yes | HubSpot marketing email object ID to update |
| name | No | New email name |
| subject | No | New subject line |
| previewText | No | New inbox preview text |
| fromName | No | New sender display name |
| replyTo | No | New reply-to address |
| sections | No | Same shapes as create_hubspot_email_draft. Replaces the entire existing body when supplied. |
| htmlBody | No | Legacy single-block mode. Replaces the entire existing body when supplied. Ignored if sections is also given. |
| templatePath | No | Template path override. Existing template is kept if omitted. |
Only the fields you supply are changed. Content merge semantics — important:
- Supplying
sectionsorhtmlBodyrebuilds the whole body from scratch and replaces whatever was there before (pass the complete intended structure, not a delta — same rule as create). - Supplying
previewTextby itself (nosections/htmlBody) leaves the existing body completely untouched — only the preheader widget changes. This is the safe path for "just tweak the preheader" without risking a body that was since hand-edited in the HubSpot UI. - Supplying neither leaves
contentout of the PATCH entirely.
Empirically verified against a real draft (16 widgets / 13 sections): a previewText-only update left all 16 widgets and 13 sections byte-for-byte identical except the preheader; a sections rebuild correctly replaced the body end to end.
elasticsearch_query_guide
Returns the bundled OS Elasticsearch query guide (no parameters, no network call): index/doc-type overview, known gotchas (keyword vs .analyzed fields, nested audienceData fields, prod/dev mapping divergence), pass-through guardrails, aggregation examples, and a zero-hits debugging checklist. Call it whenever a search unexpectedly returns 0 hits or before querying audience demographics. New gotchas discovered while using the tools should be added to docs/es-query-guide.md.
elasticsearch_get_index_info
Lists Elasticsearch indices. Call this before elasticsearch_search to discover which indices exist and what fields they hold. Without index, returns index stats plus the doc types in each index (the full mapping dump for all indices is far too large for MCP clients). With index, also returns a flattened field -> type map per doc type, with multi-field subfields listed inline, e.g. "name": "keyword (fields: analyzed, shingles)".
| Parameter | Required | Description |
|---|---|---|
| index | No | Specific index to inspect for field mappings (opensponsorship = prod, opensponsorship-dev = dev). Omit to list all indices and their doc types. |
| type | No | Doc type to narrow the field mappings to (e.g. athlete). Recommended with index — a full multi-type index (~600 fields in athleteposts alone) can exceed MCP response limits. |
elasticsearch_search
Runs a read-only Elasticsearch Query DSL search against an OS index, via the admin API pass-through (POST /api/elasticsearch/search).
| Parameter | Required | Description |
|---|---|---|
| query | Yes | Raw ES Query DSL body object, e.g. { query: { match: { "name.analyzed": "test" } } } |
| source_fields | Yes | Fields to return from _source. Required — no full document dumps. |
| index | No | Index name. Defaults to the configured OS index for the environment (a local dev server defaults to opensponsorship-dev; pass opensponsorship for prod data). |
| type | No | Document type: athlete, athleteposts, campaigns, brand, brandposts, or deal |
| size | No | Max results, 0–100 (default 10 if omitted; leave unset rather than passing 0 if query already sets its own size, e.g. for aggregation-only searches) |
Gotcha: human-readable fields like name are keyword-mapped (exact match only). For full-text matching, query the .analyzed subfield instead — { match: { "name.analyzed": "jones" } } — or a match on bare name will silently return 0 hits.
The admin route enforces the guardrails, not just the schema: any key that is (or is underscore-delimited-word-bounded to) script — script, script_score, init_script, map_script, combine_script, reduce_script, minimum_should_match_script, script_fields, ... — is rejected without also rejecting keys that merely contain the substring "script" inside an unrelated word (description, transcription, ...). function_score and wrapper queries (which can smuggle a base64-encoded script/function_score query past a plain-text scan) are also rejected, as are docvalue_fields, inner_hits, highlight, stored_fields, top_hits (the only aggregation type that can return raw documents — it defaults to full _source: true and can set its own _source/highlight/stored_fields), update_by_query, and delete_by_query. _source must be a non-empty list of concrete field names — wildcards like "*" are rejected. size is capped at 100 and from at 10,000 (deep pagination is expensive regardless of the returned hit count), and responses are truncated to 50 hits (with truncated: true set). Aggregations are supported and returned alongside hits.
For admins: install without cloning the repo
After this package is published to npm (or your team's npm registry), install is one command — no git clone, no monorepo.
Claude Code (recommended) — from your project directory:
claude mcp add --transport stdio --scope user \
--env OS_ADMIN_URL=https://admin.opensponsorship.com \
os-admin -- npx -y opensponsorship-os-admin-mcpThen restart Claude Code and run /mcp — os-admin should appear.
Cursor — in MCP settings, add a server:
- Command:
npx - Args:
-y,opensponsorship-os-admin-mcp - Env:
OS_ADMIN_URL=https://admin.opensponsorship.com
Use OS_ADMIN_MCP_PORT if port 3457 is already in use.
Pin a version (optional)
npx -y [email protected]For maintainers: publish this package
From mcp-servers/os-admin in the monorepo:
npm install
npm publish --access publicUse --access public only for scoped names on npm. If you publish to a private registry (for example Google Artifact Registry), configure publishConfig.registry and auth per your org's docs.
Until the package is published, teammates can still run from a checkout: node /path/to/mcp-servers/os-admin/index.js (after npm install in that folder).
Private npm registry (e.g. Google Artifact Registry)
If you do not want this on the public npm registry, publish to your org registry instead (scoped name such as @opensponsorship/os-admin-mcp). Admins then keep a one-line ~/.npmrc (team-standard) so npx -y @opensponsorship/os-admin-mcp resolves — still no git clone. Exact publishConfig and auth are internal to your cloud account.
Interim: ship a tarball
Before CI/npm is ready, you can npm pack in this folder and share opensponsorship-os-admin-mcp-1.1.0.tgz. Install with npm install -g ./opensponsorship-os-admin-mcp-1.1.0.tgz and point your MCP command at the global opensponsorship-os-admin-mcp binary.
Configuration
| Env var | Default | Description |
|---|---|---|
| OS_ADMIN_URL | https://admin.opensponsorship.com | Admin API base (no trailing slash) |
| OS_ADMIN_MCP_PORT | 3457 | Local port for the one-time browser sign-in page |
Token cache: ~/.os-admin-token.json (credentials are not stored; only the JWT). Delete this file to force a new sign-in.
First use
- Ask your agent to run
create_hubspot_draftwithnameandhtmlBody. - Open http://127.0.0.1:3457/login if the browser does not open automatically.
- Sign in with your OpenSponsorship admin account (same as the admin portal). The MCP calls
POST /api/signinonOS_ADMIN_URLand caches the JWT. - The tool completes the HubSpot draft request using
x-access-token.
Manual run (debug)
OS_ADMIN_URL=https://admin.opensponsorship.com npx opensponsorship-os-admin-mcpOr from a git checkout (after npm install): node index.js (stdio — pair with MCP Inspector).
Security notes
- Sign-in binds to 127.0.0.1 only.
- Do not commit
~/.os-admin-token.json. - HubSpot calls use the portal private app on the server; the admin JWT only authorises the admin API.
Changelog
1.10.0
update_hubspot_email_draft(Phase 3, seehs_marketing_email_draft_brief.md): new tool — PATCH an existing email draft in place (name/subject/preview/from/body) instead of creating a duplicate.sections/htmlBodyrebuild the whole body; apreviewText-only call preserves the existing body untouched. Empirically verified against a real 16-widget/13-section draft.
1.9.0
create_hubspot_email_draft(Phase 2, seehs_marketing_email_draft_brief.md): addedsectionsparam — composes a real multi-column, multi-section email layout (textImageRow,columns,mediaGrid,richText) from independently-editable widgets instead of one flattened body block or a hand-exported image. Wins overhtmlBodyif both given. End-to-end validated by recreating a real published email's full layout with real widgets.
1.8.0
create_hubspot_email_draft(Phase 1, seehs_marketing_email_draft_brief.md):htmlBodynow renders as an editable rich-text module andpreviewTextpersists correctly, via empirically-verified (undocumented) HubSpotcontent.widgets/content.flexAreasshapes; addedtemplatePathparam- Fixed
editUrl/editorUrlcoming backnullon all draft tools (pages, posts, emails) —resolveHubId()now tries/account-info/v3/details(works with private-app tokens) before falling back to the OAuth-only token-info endpoint
1.7.0
upload_hubspot_file— upload local files to HubSpot file manager; returns CDNurlfor blog HTML and featured imagecreate_hubspot_draft/update_hubspot_post:featuredImageandfeaturedImageAltText(blog posts)- Requires admin API
POST /api/hubspot/upload-fileand HubSpot private appfilesscope
1.6.0
create_hubspot_draft: optionaluseBlankCanvas— opt-inblank-canvas.htmltemplate when the user explicitly requests a custom-designed page (no theme header). Server defaults unchanged when omitted.- Response includes
templatePathandtemplateSource(templatePath|useBlankCanvas|serverDefault).
1.5.0
get_hubspot_post_analytics— blog post performance metrics by ID and date rangeget_hubspot_page_analytics— site/landing page metrics (pageKindrequired)
1.1.0
create_hubspot_draftnow supportslandingandblogdestination types in addition tocustom/website pages- Landing pages are automatically given a
learn-more/slug prefix - If
postDraftDestinationis omitted the tool returns a clarifying question instead of guessing postDraftDestinationandpageKindinputs are normalised (trim + lowercase) to avoid casing issues
1.0.0
- Initial release:
create_hubspot_draftfor HubSpot CMS site pages with browser sign-in auth
