@affset/mcp
Advanced tools
| import { z } from "zod"; | ||
| import { mdCell } from "../lib/format.js"; | ||
| import { errorResult, textError, textResult } from "../lib/toolResult.js"; | ||
| /** Matches lite-adserver's TENANT_ROLES. */ | ||
| const TENANT_ROLES = [ | ||
| "owner", | ||
| "manager", | ||
| "advertiser_manager", | ||
| "publisher_manager", | ||
| "advertiser", | ||
| "publisher", | ||
| ]; | ||
| /** manager_email is only meaningful for these two roles. */ | ||
| const MANAGED_ROLES = ["advertiser", "publisher"]; | ||
| const PERMISSION_VALUES = ["read", "write"]; | ||
| const MAX_EPOCH_MS = 8_640_000_000_000_000; | ||
| /** affset tokens are `sk_live_` + 64 hex (~72 chars). Bound what we echo into model context. */ | ||
| const MAX_TOKEN_LENGTH = 200; | ||
| export const CREATE_TEAM_MEMBER_DESCRIPTION = "Invite a team member: create a user API key with an email in the current namespace — " + | ||
| 'the same operation as the dashboard\'s "Add Team Member". Requires owner/manager, or a ' + | ||
| "scoped manager role (publisher_manager / advertiser_manager), which can only create its " + | ||
| "own managed role (publisher / advertiser respectively) assigned to itself — the API " + | ||
| "enforces this, not this tool, so a scoped manager's role/manager_email args may be " + | ||
| "overridden. Returns the new member's plaintext API key ONCE, in the confirmed response " + | ||
| "— it is not shown again by list_team, which deliberately never echoes tokens, so copy " + | ||
| "it now and send it to them over a private channel. Does not send an invite email; the " + | ||
| "API key is the credential, handed over out of band. " + | ||
| "DRY-RUN by default; pass confirm=true to apply."; | ||
| export const createTeamMemberInputSchema = { | ||
| email: z | ||
| .string() | ||
| .trim() | ||
| .email() | ||
| .describe("New team member's email — their login / API identity."), | ||
| role: z | ||
| .enum(TENANT_ROLES) | ||
| .describe("owner | manager | advertiser_manager | publisher_manager | advertiser | publisher. " + | ||
| "What your own API key's role is allowed to create is enforced by the API."), | ||
| manager_email: z | ||
| .string() | ||
| .trim() | ||
| .email() | ||
| .optional() | ||
| .describe("Assign to a publisher_manager / advertiser_manager. Only valid with role=publisher or " + | ||
| "role=advertiser. A scoped manager key ignores this and assigns itself instead."), | ||
| permissions: z | ||
| .array(z.enum(PERMISSION_VALUES)) | ||
| .nonempty() | ||
| .optional() | ||
| .describe('Defaults to ["read","write"] (matches the dashboard\'s default). "read" is always included.'), | ||
| expires_at: z | ||
| .number() | ||
| .int() | ||
| .positive() | ||
| .max(MAX_EPOCH_MS) | ||
| .optional() | ||
| .describe("Optional future expiry as epoch milliseconds. Omit for a key that never expires."), | ||
| confirm: z | ||
| .boolean() | ||
| .default(false) | ||
| .describe("false = dry-run preview (default). true = create the member and issue the key."), | ||
| }; | ||
| export async function createTeamMember(client, args) { | ||
| try { | ||
| const email = args.email.trim(); | ||
| if (!email) | ||
| return textError("email is required."); | ||
| const managerEmail = args.manager_email?.trim(); | ||
| if (args.manager_email !== undefined && !managerEmail) { | ||
| return textError("manager_email must not be blank when provided."); | ||
| } | ||
| if (managerEmail !== undefined && !MANAGED_ROLES.includes(args.role)) { | ||
| return textError("manager_email is only allowed with role=publisher or role=advertiser."); | ||
| } | ||
| const expiryError = validateExpiry(args.expires_at); | ||
| if (expiryError) | ||
| return textError(expiryError); | ||
| const permissions = normalizePermissions(args.permissions); | ||
| const expiresNote = args.expires_at !== undefined ? fmtDay(args.expires_at) : "never"; | ||
| const summaryTable = [ | ||
| "| Field | Value |", | ||
| "|---|---|", | ||
| `| Email | ${mdCell(email)} |`, | ||
| `| Role | ${mdCell(args.role)} |`, | ||
| `| Manager | ${mdCell(managerEmail ?? "—")} |`, | ||
| `| Permissions | ${permissions.join(", ")} |`, | ||
| `| Expires | ${mdCell(expiresNote)} |`, | ||
| ].join("\n"); | ||
| if (!args.confirm) { | ||
| return textResult([ | ||
| "**Dry run** — would create a team member with:", | ||
| "", | ||
| summaryTable, | ||
| "", | ||
| "Call again with `confirm: true` to create it and issue the API key. A scoped " + | ||
| "manager key (publisher_manager / advertiser_manager) can only create its own " + | ||
| "managed role, assigned to itself — the fields above may be overridden by the API.", | ||
| ].join("\n")); | ||
| } | ||
| const body = { email, role: args.role, permissions }; | ||
| if (managerEmail !== undefined) | ||
| body.manager_email = managerEmail; | ||
| if (args.expires_at !== undefined) | ||
| body.expires_at = args.expires_at; | ||
| // Treat the response as untrusted at runtime. Most importantly, do not let a | ||
| // malformed optional field throw after the non-idempotent POST succeeded: that | ||
| // would report an error and make a duplicate retry look appropriate. | ||
| const rawCreated = await client.post("/api/api-keys?type=user", body); | ||
| const created = isRecord(rawCreated) ? rawCreated : {}; | ||
| const createdEmail = stringField(created.email, email); | ||
| const createdRole = stringField(created.role, args.role); | ||
| const createdManager = nullableStringField(created.manager_email, managerEmail ?? "—"); | ||
| const createdPermissions = permissionFields(created.permissions, permissions); | ||
| const createdExpiry = expiryField(created.expires_at, expiresNote); | ||
| const token = extractToken(created.token); | ||
| const result = [ | ||
| `✅ Team member **${mdCell(createdEmail)}** created.`, | ||
| "", | ||
| "| Field | Value |", | ||
| "|---|---|", | ||
| `| Email | ${mdCell(createdEmail)} |`, | ||
| `| Role | ${mdCell(createdRole)} |`, | ||
| `| Manager | ${mdCell(createdManager)} |`, | ||
| `| Permissions | ${createdPermissions.map(mdCell).join(", ")} |`, | ||
| `| Expires | ${mdCell(createdExpiry)} |`, | ||
| "", | ||
| ]; | ||
| if (token) { | ||
| result.push("**API key** (shown once here — treat it like a password, send it over a private channel):", secretBlock(token), "`list_team` will show this person from now on, but never their token. If it leaks, " + | ||
| "revoke access from the dashboard's Team page."); | ||
| } | ||
| else { | ||
| result.push("⚠️ The member was created, but the API response did not include a usable API key. " + | ||
| "Do not retry this create call: that could create a duplicate. Retrieve or rotate " + | ||
| "the key from the dashboard's Team page."); | ||
| } | ||
| return textResult(result.join("\n")); | ||
| } | ||
| catch (err) { | ||
| return errorResult(err); | ||
| } | ||
| } | ||
| function normalizePermissions(raw) { | ||
| const set = new Set(raw && raw.length > 0 ? raw : PERMISSION_VALUES); | ||
| set.add("read"); | ||
| return PERMISSION_VALUES.filter((permission) => set.has(permission)); | ||
| } | ||
| function fmtDay(ms) { | ||
| return new Date(ms).toISOString().split("T", 1)[0]; | ||
| } | ||
| function validateExpiry(ms) { | ||
| if (ms === undefined) | ||
| return null; | ||
| if (!Number.isInteger(ms) || ms <= 0 || ms > MAX_EPOCH_MS) { | ||
| return "expires_at must be a valid positive epoch-millisecond timestamp."; | ||
| } | ||
| if (ms <= Date.now()) | ||
| return "expires_at must be in the future."; | ||
| return null; | ||
| } | ||
| function isRecord(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
| function extractToken(value) { | ||
| if (typeof value !== "string") | ||
| return null; | ||
| const token = value.trim(); | ||
| if (!token || token.length > MAX_TOKEN_LENGTH) | ||
| return null; | ||
| return token; | ||
| } | ||
| function stringField(value, fallback) { | ||
| return typeof value === "string" && value.trim() ? value : fallback; | ||
| } | ||
| function nullableStringField(value, fallback) { | ||
| if (value === null) | ||
| return "—"; | ||
| return stringField(value, fallback); | ||
| } | ||
| function permissionFields(value, fallback) { | ||
| if (!Array.isArray(value)) | ||
| return fallback; | ||
| const permissions = value.filter((permission) => typeof permission === "string" && permission.trim() !== ""); | ||
| return permissions.length > 0 ? permissions : fallback; | ||
| } | ||
| function expiryField(value, fallback) { | ||
| if (value === null) | ||
| return "never"; | ||
| if (typeof value !== "number" || !Number.isFinite(value)) | ||
| return fallback; | ||
| try { | ||
| return fmtDay(value); | ||
| } | ||
| catch { | ||
| return fallback; | ||
| } | ||
| } | ||
| /** Use a fence longer than any backtick run in the secret, so it cannot close the block. */ | ||
| function secretBlock(secret) { | ||
| const longestRun = Math.max(0, ...(secret.match(/`+/g) ?? []).map((run) => run.length)); | ||
| const fence = "`".repeat(Math.max(3, longestRun + 1)); | ||
| return `${fence}\n${secret}\n${fence}`; | ||
| } | ||
| //# sourceMappingURL=createTeamMember.js.map |
+12
-0
@@ -9,2 +9,3 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { listTeam, listTeamInputSchema, LIST_TEAM_DESCRIPTION } from "./tools/listTeam.js"; | ||
| import { createTeamMember, createTeamMemberInputSchema, CREATE_TEAM_MEMBER_DESCRIPTION, } from "./tools/createTeamMember.js"; | ||
| import { createZone, createZoneInputSchema, CREATE_ZONE_DESCRIPTION } from "./tools/createZone.js"; | ||
@@ -106,2 +107,13 @@ import { getZoneUrl, getZoneUrlInputSchema, GET_ZONE_URL_DESCRIPTION } from "./tools/getZoneUrl.js"; | ||
| }, (args) => listTeam(client, args)); | ||
| registerTool("create_team_member", { | ||
| title: "Invite a team member", | ||
| description: CREATE_TEAM_MEMBER_DESCRIPTION, | ||
| inputSchema: createTeamMemberInputSchema, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }, | ||
| }, (args) => createTeamMember(client, args)); | ||
| registerTool("get_zone_url", { | ||
@@ -108,0 +120,0 @@ title: "Get the zone URL for a traffic source", |
@@ -16,3 +16,4 @@ import { z } from "zod"; | ||
| "email, offer URL, geo whitelist, payout, name. The advertiser (user_email) is required " + | ||
| "— it must already exist as a team member (same as the Advertiser dropdown in the dashboard). " + | ||
| "— it must already exist as a team member (same as the Advertiser dropdown in the dashboard); " + | ||
| "create one first with create_team_member if needed. " + | ||
| "Everything else gets media-buying defaults: CPA model, " + | ||
@@ -33,3 +34,3 @@ "rate 0 (no internal advertiser billing), created paused, a global payout rule when " + | ||
| "Must already exist as a team member with the advertiser role; same as the dashboard's " + | ||
| "Advertiser dropdown. List candidates with list_team."), | ||
| "Advertiser dropdown. List candidates with list_team, or create one with create_team_member."), | ||
| offer_url: z | ||
@@ -36,0 +37,0 @@ .string() |
+2
-1
| { | ||
| "name": "@affset/mcp", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "mcpName": "io.github.affset/mcp", | ||
| "description": "MCP server for the affset ad platform — stats, campaigns, zones, payouts, targeting, sub labels, and team from your chat client.", | ||
@@ -5,0 +6,0 @@ "type": "module", |
+71
-27
@@ -12,28 +12,29 @@ # affset MCP server | ||
| | Tool | What it does | | ||
| | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. | | ||
| | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, …). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. | | ||
| | `list_campaigns` | List campaigns (status / name filter, pagination). | | ||
| | `list_zones` | List traffic-source zones (status / name filter, pagination). | | ||
| | `list_team` | List team members (email, role, manager). **Never returns API tokens.** | | ||
| | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. | | ||
| | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. | | ||
| | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. | | ||
| | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. | | ||
| | `get_tracking_link` | The `/track/click` link for an existing campaign + zone — straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. | | ||
| | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. | | ||
| | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. | | ||
| | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. | | ||
| | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_sub_labels` | List tenant display names for sub1–sub5. | | ||
| | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. | | ||
| | Tool | What it does | | ||
| | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `whoami` | Show the tenant this server is bound to: namespace, API base, derived dashboard URL, and (when readable) company / timezone / custom API domain. Read-only. | | ||
| | `get_stats` | Traffic stats grouped by a dimension (date, campaign, zone, country, sub1–5, …). Returns clicks, conversions, CR, payout, media cost and ROI as a table. Sub columns use the tenant's sub labels when configured. | | ||
| | `list_campaigns` | List campaigns (status / name filter, pagination). | | ||
| | `list_zones` | List traffic-source zones (status / name filter, pagination). | | ||
| | `list_team` | List team members (email, role, manager). **Never returns API tokens.** | | ||
| | `create_team_member` | Invite a team member (owner, manager, publisher, advertiser, publisher_manager, advertiser_manager). A scoped manager key can only create its own managed role, self-assigned. Returns the new API key **once** — `list_team` never shows it again. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `create_campaign` | Create a campaign from an advertiser email, offer URL, geo, payout and name. Defaults: CPA / rate 0, **paused**, global payout rule, ready tracking link with `source_click_id={clickid}` + sub placeholders. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `set_campaign_status` | **Run** or **pause** a campaign (`action: "run" \| "pause"`). **Dry-run by default**; `confirm: true` to apply. Running can hit the plan's active-campaign limit. | | ||
| | `update_campaign` | Partial update (name, offer URL, status, rate, budgets, dates, …). **Dry-run by default**; `confirm: true` to apply. Prefer `set_campaign_status` for run/pause. | | ||
| | `create_zone` | Create a traffic-source zone (name + optional postback/site/traffic-back URLs). Always created `active`. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `update_zone` | Partial update (name, status, URLs). **Dry-run by default**; `confirm: true` to apply. Pass `null` to clear a URL. | | ||
| | `get_zone_url` | The `/serve` URL to paste into a network's campaign settings — rotates across the zone's **active** campaigns. Prefilled sub convention, optional `cost` macro. Warns when no active campaigns are visible. | | ||
| | `get_tracking_link` | The `/track/click` link for an existing campaign + zone — straight to one active campaign, with no rotation or targeting checks. Re-derives what `create_campaign` echoed on create. | | ||
| | `cut_zones` | Blacklist underperforming zones on a campaign by threshold (CR / spend / ROI). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_payout_rules` | List a campaign's global + per-zone payout rules and its `payout_goal_type`. | | ||
| | `set_payout_rule` | Upsert a global or zone-specific payout. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `delete_payout_rule` | Delete a global or zone-specific payout rule. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `set_payout_goal` | Set or clear `payout_goal_type` (goal-based conversions). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_targeting_types` | Catalog of targeting rule types, flagging the seeded ones `/serve` never evaluates. | | ||
| | `list_targeting_rules` | List a campaign's targeting rules, flagging any that have no effect. | | ||
| | `set_targeting_rule` | Upsert one targeting rule (safe merge), normalised to what `/serve` matches. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `remove_targeting_rule` | Remove one targeting rule by id or type+method. **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_sub_labels` | List tenant display names for sub1–sub5. | | ||
| | `set_sub_labels` | Set or clear sub labels (partial; `null` clears). **Dry-run by default**; `confirm: true` to apply. | | ||
| | `list_conversions` | List conversion audit records (payout, spend, pixel type, payload, postback). Optional client-side filters on the current page. | | ||
@@ -114,5 +115,40 @@ ### Which URL do I give the network? | ||
| Same env flags with `-- npx -y github:affset/mcp` if you install from GitHub | ||
| instead of the npm registry (see below). | ||
| Add `-e AFFSET_READ_ONLY=true` for a stats/reporting-only instance (see | ||
| [Security](#security)). | ||
| ### From GitHub directly (no npm publish required) | ||
| `npx` can install straight from the git repo instead of the npm registry — | ||
| useful if you'd rather not publish, or just want to track `main` without a | ||
| release step: | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "affset": { | ||
| "command": "npx", | ||
| "args": ["-y", "github:affset/mcp"], | ||
| "env": { | ||
| "AFFSET_BASE_URL": "https://api.affset.com", | ||
| "AFFSET_API_KEY": "sk_live_...", | ||
| "AFFSET_NAMESPACE": "your-namespace" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| A push to `main` makes that commit available to this unpinned install path — no | ||
| npm publish is required. On resolution, npm fetches the repository and runs the | ||
| `prepare` script to build `dist/` before starting the binary. npm may reuse its | ||
| cache on later starts; an already running MCP process is not updated until it is | ||
| restarted and `npx` resolves the dependency again. | ||
| For reproducible deployments, pin a reviewed ref instead of floating on `main`: | ||
| `github:affset/mcp#<commit-sha>` or `github:affset/mcp#<tag>`. Restart the MCP | ||
| process deliberately when you want it to resolve and run a newer revision. | ||
| ### From source | ||
@@ -137,2 +173,4 @@ | ||
| > | ||
| > **add sarah@offer.com as a publisher** → `create_team_member(email: "sarah@offer.com", role: "publisher")` (dry-run) → confirm | ||
| > | ||
| > **stats for today by sub1** → `get_stats(group_by: "sub1")` | ||
@@ -212,3 +250,7 @@ > | ||
| advertiser-side roles do not see `payout`, so `zero_payout` needs a role that can. | ||
| - Out of scope: deleting campaigns/zones/conversions, billing, creative management, invite flows. | ||
| - **`create_team_member`** creates the API key directly (like the dashboard's "Add Team | ||
| Member") — it does not send an invite email. Hand the returned key to the person | ||
| yourself. Revoking/removing a team member is not yet a tool; use the dashboard's | ||
| Team page. | ||
| - Out of scope: deleting campaigns/zones/conversions, billing, creative management. | ||
| - **Tenant signup is deliberately not a tool.** `POST /api/public/create-instance` | ||
@@ -244,2 +286,4 @@ is Origin-gated and fails closed, which is what keeps signup browser-only; a | ||
| advertiser) apply to MCP tool calls exactly as they do to the dashboard. | ||
| - Pin GitHub installs to a reviewed commit or tag in long-lived environments. A | ||
| floating `main` spec can run newer repository code the next time `npx` resolves it. | ||
@@ -246,0 +290,0 @@ ### Prompt injection via conversion/click data |
217791
6.27%44
2.33%4272
5.33%308
16.67%