# Event Tags & Properties Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Allow MCPCat customers to attach structured tags (string key-value pairs) and flexible properties (JSON) to every auto-captured event via callbacks, and directly on custom events.

**Architecture:** Two new callback options (`eventTags`, `eventProperties`) on `MCPCatOptions` are resolved at each instrumentation point via shared helpers in `internal.ts`. Tags undergo client-side validation in a new `validation.ts` module. Both fields are top-level on the event and carried through the publish pipeline to the MCPCat API (via upgraded `mcpcat-api@0.1.8`) and all telemetry exporters. Neither field goes through redaction, sanitization, or truncation.

**Tech Stack:** TypeScript, Vitest, `mcpcat-api@0.1.8`, `@modelcontextprotocol/sdk`

**Spec:** `docs/superpowers/specs/2026-03-18-custom-fields-design.md`

---

## File Map

| Action | File                                      | Responsibility                                                                   |
| ------ | ----------------------------------------- | -------------------------------------------------------------------------------- |
| Modify | `package.json`                            | Upgrade `mcpcat-api` to `0.1.8`                                                  |
| Modify | `src/types.ts`                            | Add types to `MCPCatOptions`, `UnredactedEvent`, `Event`, `CustomEventData`      |
| Create | `src/modules/validation.ts`               | `validateTags()` function                                                        |
| Create | `src/tests/validation.test.ts`            | Unit tests for `validateTags()`                                                  |
| Modify | `src/modules/internal.ts`                 | Add `resolveEventTags()` and `resolveEventProperties()` helpers                  |
| Modify | `src/modules/eventQueue.ts`               | Carry `tags`/`properties` through `publishEvent()` and `toPublishEventRequest()` |
| Modify | `src/modules/tracingV2.ts`                | Add resolve calls in `createToolsCallWrapper()`                                  |
| Modify | `src/modules/tracing.ts`                  | Add resolve calls in all 4 handler paths                                         |
| Modify | `src/index.ts`                            | Thread options through `track()`, wire up `publishCustomEvent()`                 |
| Create | `src/tests/event-tags-properties.test.ts` | Integration tests for the full feature                                           |
| Modify | `src/modules/exporters/otlp.ts`           | Include tags/properties in OTLP spans                                            |
| Modify | `src/modules/exporters/datadog.ts`        | Include tags/properties in Datadog logs/metrics                                  |
| Modify | `src/modules/exporters/sentry.ts`         | Include tags/properties in Sentry events                                         |
| Modify | `src/modules/exporters/posthog.ts`        | Include tags/properties in PostHog events                                        |

---

### Task 1: Upgrade `mcpcat-api` to 0.1.8

**Files:**

- Modify: `package.json`

- [ ] **Step 1: Upgrade the dependency**

```bash
pnpm add mcpcat-api@0.1.8
```

- [ ] **Step 2: Verify the new types exist**

```bash
grep -r "tags" node_modules/mcpcat-api/src/models/PublishEventRequest.ts | head -5
grep -r "properties" node_modules/mcpcat-api/src/models/PublishEventRequest.ts | head -5
```

Expected: Both `tags` and `properties` fields are present in `PublishEventRequest`.

- [ ] **Step 3: Run existing tests to confirm nothing breaks**

```bash
pnpm test -- --run
```

Expected: All existing tests pass.

---

### Task 2: Add type definitions

**Files:**

- Modify: `src/types.ts:3-15` (MCPCatOptions)
- Modify: `src/types.ts:52-91` (Event)
- Modify: `src/types.ts:93-95` (UnredactedEvent — inherits from Event, no change needed)
- Modify: `src/types.ts:206-214` (CustomEventData)

- [ ] **Step 1: Add `eventTags` and `eventProperties` to `MCPCatOptions`**

In `src/types.ts`, add after the `redactSensitiveInformation` line (line 12):

```typescript
  eventTags?: (
    request: any,
    extra?: CompatibleRequestHandlerExtra,
  ) => Record<string, string> | null | Promise<Record<string, string> | null>;
  eventProperties?: (
    request: any,
    extra?: CompatibleRequestHandlerExtra,
  ) => Record<string, any> | null | Promise<Record<string, any> | null>;
```

- [ ] **Step 2: Add `tags` and `properties` to `Event`**

In `src/types.ts`, add after the `error?: ErrorData;` line (line 85) in the `Event` interface:

```typescript
  // Customer-defined metadata
  tags?: Record<string, string> | null;
  properties?: Record<string, any> | null;
```

Note: `UnredactedEvent` extends `Partial<Event>`, so it inherits these fields automatically.

- [ ] **Step 3: Add `tags` and `properties` to `CustomEventData`**

In `src/types.ts`, add after the `error?: any;` line (line 213) in `CustomEventData`:

```typescript
  tags?: Record<string, string>;
  properties?: Record<string, any>;
```

- [ ] **Step 4: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 3: Implement `validateTags()`

**Files:**

- Create: `src/modules/validation.ts`
- Create: `src/tests/validation.test.ts`

- [ ] **Step 1: Write the failing tests**

Create `src/tests/validation.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock logging before importing validation
vi.mock("../modules/logging.js", () => ({
  writeToLog: vi.fn(),
}));

import { validateTags } from "../modules/validation.js";
import { writeToLog } from "../modules/logging.js";

describe("validateTags", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("should pass through valid tags unchanged", () => {
    const tags = {
      env: "production",
      trace_id: "abc-123",
      region: "us-east-1",
    };
    expect(validateTags(tags)).toEqual(tags);
  });

  it("should return null for empty object", () => {
    expect(validateTags({})).toBeNull();
  });

  it("should drop keys with invalid characters", () => {
    const tags = {
      valid_key: "value",
      "invalid!key": "value",
      "good.key": "value",
    };
    const result = validateTags(tags);
    expect(result).toEqual({ valid_key: "value", "good.key": "value" });
    expect(writeToLog).toHaveBeenCalledWith(
      expect.stringContaining("invalid!key"),
    );
  });

  it("should drop keys longer than 32 characters", () => {
    const longKey = "a".repeat(33);
    const tags = { [longKey]: "value", short: "value" };
    const result = validateTags(tags);
    expect(result).toEqual({ short: "value" });
    expect(writeToLog).toHaveBeenCalledWith(
      expect.stringContaining("exceeds max length"),
    );
  });

  it("should drop values longer than 200 characters", () => {
    const longValue = "a".repeat(201);
    const tags = { key1: longValue, key2: "short" };
    const result = validateTags(tags);
    expect(result).toEqual({ key2: "short" });
    expect(writeToLog).toHaveBeenCalledWith(
      expect.stringContaining("exceeds max length"),
    );
  });

  it("should drop values containing newlines", () => {
    const tags = { key1: "has\nnewline", key2: "clean" };
    const result = validateTags(tags);
    expect(result).toEqual({ key2: "clean" });
    expect(writeToLog).toHaveBeenCalledWith(expect.stringContaining("newline"));
  });

  it("should drop non-string values at runtime", () => {
    const tags = { key1: 123, key2: "valid" } as any;
    const result = validateTags(tags);
    expect(result).toEqual({ key2: "valid" });
    expect(writeToLog).toHaveBeenCalledWith(
      expect.stringContaining("non-string"),
    );
  });

  it("should drop non-string keys at runtime", () => {
    // Object.entries will coerce numeric keys to strings, but symbol keys are skipped
    // This tests the typeof check for values more than keys
    const tags = { "": "empty-key-value", valid: "value" };
    const result = validateTags(tags);
    // Empty string key should fail the regex
    expect(result).toEqual({ valid: "value" });
  });

  it("should keep only first 50 entries when exceeding limit", () => {
    const tags: Record<string, string> = {};
    for (let i = 0; i < 60; i++) {
      tags[`key${String(i).padStart(3, "0")}`] = `value${i}`;
    }
    const result = validateTags(tags);
    expect(result).not.toBeNull();
    expect(Object.keys(result!).length).toBe(50);
    expect(writeToLog).toHaveBeenCalledWith(
      expect.stringContaining("Dropping 10"),
    );
  });

  it("should return null when all entries are invalid", () => {
    const tags = { "!!!": "value", "###": "value" };
    expect(validateTags(tags)).toBeNull();
  });

  it("should allow keys with periods, colons, dashes, and spaces", () => {
    const tags = {
      "my.tag": "value",
      "my:tag": "value",
      "my-tag": "value",
      "my tag": "value",
    };
    expect(validateTags(tags)).toEqual(tags);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
pnpm test -- --run src/tests/validation.test.ts
```

Expected: FAIL — module `../modules/validation.js` not found.

- [ ] **Step 3: Write the implementation**

Create `src/modules/validation.ts`:

```typescript
import { writeToLog } from "./logging.js";

const TAG_KEY_REGEX = /^[a-zA-Z0-9_.:\- ]+$/;
const MAX_TAG_KEY_LENGTH = 32;
const MAX_TAG_VALUE_LENGTH = 200;
const MAX_TAG_ENTRIES = 50;

/**
 * Validates and filters a tags object against MCPCat tag constraints.
 * Invalid entries are logged as warnings and dropped.
 * Returns null if no valid entries remain.
 */
export function validateTags(
  tags: Record<string, string>,
): Record<string, string> | null {
  const entries = Object.entries(tags);

  if (entries.length === 0) {
    return null;
  }

  const valid: [string, string][] = [];

  for (const [key, value] of entries) {
    // Key validation
    if (typeof key !== "string" || !TAG_KEY_REGEX.test(key)) {
      writeToLog(
        `Dropping invalid tag: "${String(key)}" — key contains invalid characters or is empty`,
      );
      continue;
    }

    if (key.length > MAX_TAG_KEY_LENGTH) {
      writeToLog(
        `Dropping invalid tag: "${key}" — key exceeds max length of ${MAX_TAG_KEY_LENGTH}`,
      );
      continue;
    }

    // Value validation
    if (typeof value !== "string") {
      writeToLog(
        `Dropping invalid tag: "${key}" — non-string value (got ${typeof value})`,
      );
      continue;
    }

    if (value.length > MAX_TAG_VALUE_LENGTH) {
      writeToLog(
        `Dropping invalid tag: "${key}" — value exceeds max length of ${MAX_TAG_VALUE_LENGTH}`,
      );
      continue;
    }

    if (value.includes("\n")) {
      writeToLog(
        `Dropping invalid tag: "${key}" — value contains newline character`,
      );
      continue;
    }

    valid.push([key, value]);
  }

  if (valid.length === 0) {
    return null;
  }

  if (valid.length > MAX_TAG_ENTRIES) {
    const dropped = valid.length - MAX_TAG_ENTRIES;
    writeToLog(
      `Dropping ${dropped} tag(s) — exceeds maximum of ${MAX_TAG_ENTRIES} entries per event`,
    );
    valid.length = MAX_TAG_ENTRIES;
  }

  return Object.fromEntries(valid);
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
pnpm test -- --run src/tests/validation.test.ts
```

Expected: All tests pass.

---

### Task 4: Add resolve helpers to `internal.ts`

**Files:**

- Modify: `src/modules/internal.ts`

- [ ] **Step 1: Add imports**

At the top of `src/modules/internal.ts`, add `validateTags` to imports:

```typescript
import { validateTags } from "./validation.js";
```

- [ ] **Step 2: Add `resolveEventTags` and `resolveEventProperties` functions**

Add at the end of `src/modules/internal.ts`:

```typescript
/**
 * Resolves the eventTags callback, validates the result, and returns validated tags.
 * Returns null if no callback configured, callback returns nullish, or callback throws.
 */
export async function resolveEventTags(
  data: MCPCatData,
  request: any,
  extra?: CompatibleRequestHandlerExtra,
): Promise<Record<string, string> | null> {
  if (!data.options.eventTags) return null;
  try {
    const raw = (await data.options.eventTags(request, extra)) ?? null;
    if (!raw) return null;
    return validateTags(raw);
  } catch (e) {
    writeToLog(`eventTags callback error: ${e}`);
    return null;
  }
}

/**
 * Resolves the eventProperties callback and returns the result.
 * Returns null if no callback configured, callback returns nullish, or callback throws.
 */
export async function resolveEventProperties(
  data: MCPCatData,
  request: any,
  extra?: CompatibleRequestHandlerExtra,
): Promise<Record<string, any> | null> {
  if (!data.options.eventProperties) return null;
  try {
    return (await data.options.eventProperties(request, extra)) ?? null;
  } catch (e) {
    writeToLog(`eventProperties callback error: ${e}`);
    return null;
  }
}
```

- [ ] **Step 3: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 5: Wire up the event publish pipeline

**Files:**

- Modify: `src/modules/eventQueue.ts:253-288` (`publishEvent` fullEvent construction)
- Modify: `src/modules/eventQueue.ts:97-133` (`toPublishEventRequest`)

- [ ] **Step 1: Carry `tags` and `properties` through `publishEvent()`**

In `src/modules/eventQueue.ts`, in the `publishEvent()` function, add to the `fullEvent` object construction (after the `redactionFn` line, around line 287):

```typescript
    // Customer-defined metadata
    tags: eventInput.tags,
    properties: eventInput.properties,
```

- [ ] **Step 2: Add `tags` and `properties` to `toPublishEventRequest()`**

In `src/modules/eventQueue.ts`, in the `toPublishEventRequest()` method, add after the `eventId` line (around line 131):

```typescript
      // Customer-defined metadata
      tags: event.tags ?? null,
      properties: event.properties ?? null,
```

- [ ] **Step 3: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors (requires `mcpcat-api@0.1.8` from Task 1).

---

### Task 6: Add resolve calls to instrumentation points

**Files:**

- Modify: `src/modules/tracingV2.ts:278-306` (`createToolsCallWrapper`)
- Modify: `src/modules/tracing.ts:58-136` (`setupListToolsTracing`)
- Modify: `src/modules/tracing.ts:145-186` (`setupInitializeTracing`)
- Modify: `src/modules/tracing.ts:189-311` (`setupToolCallTracing`)

- [ ] **Step 1: Add imports to both files**

In `src/modules/tracingV2.ts`, add to the import from `./internal.js`:

```typescript
import {
  getServerTrackingData,
  handleIdentify,
  resolveEventTags,
  resolveEventProperties,
} from "./internal.js";
```

In `src/modules/tracing.ts`, add to the import from `./internal.js`:

```typescript
import {
  getServerTrackingData,
  handleIdentify,
  resolveEventTags,
  resolveEventProperties,
} from "./internal.js";
```

- [ ] **Step 2: Add resolve calls in `createToolsCallWrapper` (tracingV2.ts)**

In `src/modules/tracingV2.ts`, inside `createToolsCallWrapper`, after the `handleIdentify` call and `event.sessionId = data.sessionId;` line (around line 310), add:

```typescript
// Resolve customer-defined event metadata
const resolvedTags = await resolveEventTags(data, request, extra);
if (resolvedTags) event.tags = resolvedTags;
const resolvedProperties = await resolveEventProperties(data, request, extra);
if (resolvedProperties) event.properties = resolvedProperties;
```

**Important:** Use conditional assignment (`if (resolved) event.field = resolved`) so the fields are only set when non-null. This ensures events have `undefined` (omitted) tags/properties when no callbacks are configured, rather than `null`.

- [ ] **Step 3: Add resolve calls in `setupListToolsTracing` (tracing.ts)**

In `src/modules/tracing.ts`, inside `setupListToolsTracing`, after the event object is created (around line 70) and before the first `publishEvent` call. The `data` variable may be `undefined` so guard with `if (data)`:

```typescript
if (data) {
  const resolvedTags = await resolveEventTags(data, request, extra);
  if (resolvedTags) event.tags = resolvedTags;
  const resolvedProperties = await resolveEventProperties(data, request, extra);
  if (resolvedProperties) event.properties = resolvedProperties;
}
```

- [ ] **Step 4: Add resolve calls in `setupInitializeTracing` (tracing.ts)**

In `src/modules/tracing.ts`, inside `setupInitializeTracing`, after creating the `event` object (around line 178) and before `publishEvent`:

```typescript
const resolvedTags = await resolveEventTags(data, request, extra);
if (resolvedTags) event.tags = resolvedTags;
const resolvedProperties = await resolveEventProperties(data, request, extra);
if (resolvedProperties) event.properties = resolvedProperties;
```

- [ ] **Step 5: Add resolve calls in `setupToolCallTracing` — initialize handler (tracing.ts)**

In `src/modules/tracing.ts`, inside the low-level `setupToolCallTracing` initialize handler, after creating the `event` object (around line 223) and before `publishEvent`:

```typescript
const resolvedTags = await resolveEventTags(data, request, extra);
if (resolvedTags) event.tags = resolvedTags;
const resolvedProperties = await resolveEventProperties(data, request, extra);
if (resolvedProperties) event.properties = resolvedProperties;
```

- [ ] **Step 6: Add resolve calls in `setupToolCallTracing` — tools/call handler (tracing.ts)**

In `src/modules/tracing.ts`, inside the low-level tools/call handler (around line 256), add after the `handleIdentify` call and before the conditional block that dispatches to different paths:

```typescript
const resolvedTags = await resolveEventTags(data, request, extra);
if (resolvedTags) event.tags = resolvedTags;
const resolvedProperties = await resolveEventProperties(data, request, extra);
if (resolvedProperties) event.properties = resolvedProperties;
```

- [ ] **Step 7: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 7: Thread options through `track()` and wire up `publishCustomEvent()`

**Files:**

- Modify: `src/index.ts:192-199` (track options pass-through)
- Modify: `src/index.ts:322-342` (publishCustomEvent event construction)

- [ ] **Step 1: Add `eventTags` and `eventProperties` to track() options**

In `src/index.ts`, in the `mcpcatData` construction (around line 192-199), add after `redactSensitiveInformation`:

```typescript
        eventTags: options.eventTags,
        eventProperties: options.eventProperties,
```

- [ ] **Step 2: Add import for validateTags**

At the top of `src/index.ts`, add:

```typescript
import { validateTags } from "./modules/validation.js";
```

- [ ] **Step 3: Wire up tags/properties in `publishCustomEvent()`**

In `src/index.ts`, in the `publishCustomEvent()` function, after the event object construction (around line 342), before the `if (lowLevelServer...)` check, add:

```typescript
// Wire up customer-defined metadata
if (eventData?.tags) {
  event.tags = validateTags(eventData.tags);
}
if (eventData?.properties && Object.keys(eventData.properties).length > 0) {
  event.properties = eventData.properties;
}
```

- [ ] **Step 4: Verify types compile and existing tests pass**

```bash
pnpm run typecheck && pnpm test -- --run
```

Expected: All compile, all existing tests pass.

---

### Task 8: Write integration tests

**Files:**

- Create: `src/tests/event-tags-properties.test.ts`

- [ ] **Step 1: Write the test file**

Create `src/tests/event-tags-properties.test.ts`:

```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
  setupTestServerAndClient,
  resetTodos,
} from "./test-utils/client-server-factory";
import { track, publishCustomEvent } from "../index";
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types";
import { EventCapture } from "./test-utils";
import { HighLevelMCPServerLike } from "../types";

describe("Event Tags & Properties", () => {
  let server: HighLevelMCPServerLike;
  let client: any;
  let cleanup: () => Promise<void>;
  let eventCapture: EventCapture;

  beforeEach(async () => {
    resetTodos();
    const setup = await setupTestServerAndClient();
    server = setup.server;
    client = setup.client;
    cleanup = setup.cleanup;
    eventCapture = new EventCapture();
    await eventCapture.start();
  });

  afterEach(async () => {
    await eventCapture.stop();
    await cleanup();
  });

  describe("eventTags callback", () => {
    it("should attach tags to tool call events", async () => {
      track(server, "test-project", {
        eventTags: async () => ({
          env: "test",
          trace_id: "abc-123",
        }),
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.tags).toEqual({ env: "test", trace_id: "abc-123" });
    });

    it("should not block tool calls when callback throws", async () => {
      track(server, "test-project", {
        eventTags: async () => {
          throw new Error("Tags callback failed");
        },
      });

      const result = await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      expect(result.content[0].text).toContain("Added todo");

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.tags).toBeNull();
    });

    it("should handle null return from callback", async () => {
      track(server, "test-project", {
        eventTags: async () => null,
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.tags).toBeNull();
    });

    it("should validate tags from callback", async () => {
      track(server, "test-project", {
        eventTags: async () => ({
          valid: "value",
          "invalid!": "value",
        }),
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent!.tags).toEqual({ valid: "value" });
    });
  });

  describe("eventProperties callback", () => {
    it("should attach properties to tool call events", async () => {
      track(server, "test-project", {
        eventProperties: async () => ({
          device: "desktop",
          feature_flags: ["dark_mode"],
          nested: { key: "value" },
        }),
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.properties).toEqual({
        device: "desktop",
        feature_flags: ["dark_mode"],
        nested: { key: "value" },
      });
    });

    it("should not block tool calls when callback throws", async () => {
      track(server, "test-project", {
        eventProperties: async () => {
          throw new Error("Properties callback failed");
        },
      });

      const result = await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      expect(result.content[0].text).toContain("Added todo");
    });

    it("should handle null return from callback", async () => {
      track(server, "test-project", {
        eventProperties: async () => null,
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.properties).toBeUndefined();
    });
  });

  describe("both callbacks configured", () => {
    it("should attach both tags and properties to the same event", async () => {
      track(server, "test-project", {
        eventTags: async () => ({ env: "test" }),
        eventProperties: async () => ({ device: "mobile" }),
      });

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent!.tags).toEqual({ env: "test" });
      expect(toolCallEvent!.properties).toEqual({ device: "mobile" });
    });
  });

  describe("publishCustomEvent", () => {
    it("should include tags and properties passed directly", async () => {
      track(server, "test-project", {});

      await publishCustomEvent(server, "test-project", {
        resourceName: "custom-action",
        tags: { env: "production", trace_id: "xyz" },
        properties: { device: "mobile", feature_flags: ["beta"] },
      });

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const customEvent = events.find(
        (e) => e.resourceName === "custom-action",
      );
      expect(customEvent).toBeDefined();
      expect(customEvent!.tags).toEqual({ env: "production", trace_id: "xyz" });
      expect(customEvent!.properties).toEqual({
        device: "mobile",
        feature_flags: ["beta"],
      });
    });

    it("should validate tags passed directly", async () => {
      track(server, "test-project", {});

      await publishCustomEvent(server, "test-project", {
        resourceName: "custom-action",
        tags: { valid: "value", "bad!key": "value" },
      });

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const customEvent = events.find(
        (e) => e.resourceName === "custom-action",
      );
      expect(customEvent!.tags).toEqual({ valid: "value" });
    });

    it("should normalize empty properties to omission", async () => {
      track(server, "test-project", {});

      await publishCustomEvent(server, "test-project", {
        resourceName: "custom-action",
        properties: {},
      });

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const customEvent = events.find(
        (e) => e.resourceName === "custom-action",
      );
      expect(customEvent!.properties).toBeUndefined();
    });
  });

  describe("no callbacks configured", () => {
    it("should not add tags or properties when not configured", async () => {
      track(server, "test-project", {});

      await client.request(
        {
          method: "tools/call",
          params: {
            name: "add_todo",
            arguments: { text: "Test todo" },
          },
        },
        CallToolResultSchema,
      );

      await new Promise((resolve) => setTimeout(resolve, 50));
      const events = eventCapture.getEvents();
      const toolCallEvent = events.find(
        (e) => e.eventType === "mcp:tools:call",
      );
      expect(toolCallEvent).toBeDefined();
      expect(toolCallEvent!.tags).toBeUndefined();
      expect(toolCallEvent!.properties).toBeUndefined();
    });
  });
});
```

- [ ] **Step 2: Run integration tests**

```bash
pnpm test -- --run src/tests/event-tags-properties.test.ts
```

Expected: All tests pass.

- [ ] **Step 3: Run full test suite**

```bash
pnpm test -- --run
```

Expected: All existing tests still pass.

---

### Task 9: Update OTLP exporter

**Files:**

- Modify: `src/modules/exporters/otlp.ts:82-141` (`convertToOTLPSpan`)

- [ ] **Step 1: Add tags as span attributes and properties as JSON attribute**

In `src/modules/exporters/otlp.ts`, in the `convertToOTLPSpan` method, add after the existing attributes array (before the `.filter(...)` on line 135):

```typescript
        // Add customer-defined tags as individual attributes
        ...Object.entries(event.tags || {}).map(([key, value]) => ({
          key: `mcpcat.tag.${key}`,
          value: { stringValue: value },
        })),
        // Add customer-defined properties as JSON
        ...(event.properties
          ? [
              {
                key: "mcpcat.properties",
                value: { stringValue: JSON.stringify(event.properties) },
              },
            ]
          : []),
```

- [ ] **Step 2: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 10: Update Datadog exporter

**Files:**

- Modify: `src/modules/exporters/datadog.ts:134-184` (`eventToLog`)
- Modify: `src/modules/exporters/datadog.ts:13-43` (`DatadogLog` interface)

- [ ] **Step 1: Add tags/properties to the DatadogLog interface**

In `src/modules/exporters/datadog.ts`, add to the `mcp` object in the `DatadogLog` interface (after line 41):

```typescript
    tags?: Record<string, string> | null;
    properties?: Record<string, any> | null;
```

- [ ] **Step 2: Add customer tags to ddtags and properties to mcp object**

In `src/modules/exporters/datadog.ts`, in the `eventToLog` method:

After the existing tag building (lines 138-142) and **before** the `const log: DatadogLog = {` line (line 144) where `tags.join(",")` happens, add:

```typescript
// Add customer-defined tags to ddtags
if (event.tags) {
  for (const [key, value] of Object.entries(event.tags)) {
    tags.push(`${key}:${value}`);
  }
}
```

In the `mcp` object construction (around line 170), add:

```typescript
        tags: event.tags,
        properties: event.properties,
```

- [ ] **Step 3: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 11: Update Sentry exporter

**Files:**

- Modify: `src/modules/exporters/sentry.ts:371-383` (`buildTags`)
- Modify: `src/modules/exporters/sentry.ts:385-398` (`buildExtra`)

- [ ] **Step 1: Merge customer tags into Sentry tags**

In `src/modules/exporters/sentry.ts`, in the `buildTags` method, add at the end (before `return tags`):

```typescript
// Add customer-defined tags
if (event.tags) {
  for (const [key, value] of Object.entries(event.tags)) {
    tags[key] = value;
  }
}
```

- [ ] **Step 2: Merge customer properties into Sentry extra**

In `src/modules/exporters/sentry.ts`, in the `buildExtra` method, add at the end (before `return extra`):

```typescript
// Add customer-defined properties
if (event.properties) {
  extra.properties = event.properties;
}
```

- [ ] **Step 3: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 12: Update PostHog exporter

**Files:**

- Modify: `src/modules/exporters/posthog.ts:71-124` (`buildCaptureEvent`)

- [ ] **Step 1: Add tags and properties to PostHog event**

In `src/modules/exporters/posthog.ts`, in the `buildCaptureEvent` method, add after the `$set` block (before `return`):

```typescript
// Add customer-defined tags with prefix to avoid collisions
if (event.tags) {
  for (const [key, value] of Object.entries(event.tags)) {
    properties[`mcpcat_tag_${key}`] = value;
  }
}

// Add customer-defined properties under a namespace
if (event.properties) {
  properties.mcpcat_properties = event.properties;
}
```

- [ ] **Step 2: Verify types compile**

```bash
pnpm run typecheck
```

Expected: No type errors.

---

### Task 13: Final verification

- [ ] **Step 1: Run full test suite**

```bash
pnpm test -- --run
```

Expected: All tests pass (existing + new validation + new integration).

- [ ] **Step 2: Run typecheck**

```bash
pnpm run typecheck
```

Expected: No type errors.

- [ ] **Step 3: Run lint**

```bash
pnpm run lint
```

Expected: No lint errors.
