🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@affset/mcp

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@affset/mcp - npm Package Compare versions

Comparing version
0.1.0
to
0.1.1
+204
dist/tools/createTeamMember.js
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",

+3
-2

@@ -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