From 96499ca2d3acd066a7329ac0524d6a69dba8671e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 13:35:15 -0400 Subject: [PATCH 01/11] Add MCP server for media agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MCP (Model Context Protocol) server that provides tools to interact with media agents implementing the Media Agent Protocol. Features: - TypeScript types generated from OpenAPI spec - 5 MCP tools matching protocol endpoints - Standalone executable via npx - Programmatic API for embedding - Example media agent implementation - Tests and documentation ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- IMPLEMENTATION_PLAN.md | 87 +++ MEDIA_AGENT_MCP.md | 121 ++++ examples/media-agent-mcp.ts | 16 + examples/simple-media-agent.ts | 95 +++ media-agent-openapi.yaml | 784 +++++++++++++++++++++++ package-lock.json | 3 + package.json | 4 + src/__tests__/media-agent-mcp.test.ts | 22 + src/index.ts | 2 + src/media-agent-mcp.ts | 354 +++++++++++ src/media-agent-server.ts | 22 + src/types/media-agent-api.ts | 854 ++++++++++++++++++++++++++ 12 files changed, 2364 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 MEDIA_AGENT_MCP.md create mode 100644 examples/media-agent-mcp.ts create mode 100644 examples/simple-media-agent.ts create mode 100644 media-agent-openapi.yaml create mode 100644 src/__tests__/media-agent-mcp.test.ts create mode 100644 src/media-agent-mcp.ts create mode 100644 src/media-agent-server.ts create mode 100644 src/types/media-agent-api.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..cbc7c71 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,87 @@ +# Media Agent MCP Implementation + +## Completed + +โœ… All stages completed successfully + +### Stage 1: Project Setup +**Goal**: Set up TypeScript types from OpenAPI spec +**Status**: Complete + +- Downloaded media-agent-openapi.yaml from PR #136 +- Generated TypeScript types using openapi-typescript +- Added npm script for regenerating types + +### Stage 2: MCP Server Implementation +**Goal**: Build MCP server that proxies to media agent +**Status**: Complete + +Created `src/media-agent-mcp.ts` with: +- MediaAgentMCP class implementing MCP Server +- All 5 tools from the Media Agent Protocol: + - get_proposed_tactics + - manage_tactic + - tactic_context_updated + - tactic_creatives_updated + - tactic_feedback +- HTTP client to call media agent endpoints +- Full TypeScript types from OpenAPI spec + +### Stage 3: Server Entry Point +**Goal**: Create runnable MCP server +**Status**: Complete + +Created `src/media-agent-server.ts`: +- CLI entry point with shebang +- Environment variable configuration +- Error handling +- Added bin entry to package.json + +### Stage 4: Documentation & Examples +**Goal**: Document usage and provide examples +**Status**: Complete + +Created: +- MEDIA_AGENT_MCP.md - comprehensive guide +- examples/media-agent-mcp.ts - programmatic usage +- examples/simple-media-agent.ts - reference implementation +- Tests in src/__tests__/media-agent-mcp.test.ts + +### Stage 5: Build & Verify +**Goal**: Ensure everything compiles and works +**Status**: Complete + +- Installed dependencies +- Built project successfully +- Tests passing +- Generated dist files ready to use + +## Usage + +### Run the MCP Server + +```bash +export MEDIA_AGENT_URL=https://your-media-agent.example.com +export MEDIA_AGENT_API_KEY=your_api_key +npx scope3-media-agent +``` + +### Use Programmatically + +```typescript +import { MediaAgentMCP } from '@scope3/agentic-client'; + +const server = new MediaAgentMCP({ + mediaAgentUrl: 'https://your-media-agent.example.com', + apiKey: process.env.MEDIA_AGENT_API_KEY, +}); + +await server.run(); +``` + +## Next Steps + +When the API is merged to main: +1. Update media-agent-openapi.yaml from official source +2. Regenerate types: `npm run generate-media-agent-types` +3. Test with real media agent implementation diff --git a/MEDIA_AGENT_MCP.md b/MEDIA_AGENT_MCP.md new file mode 100644 index 0000000..16e68be --- /dev/null +++ b/MEDIA_AGENT_MCP.md @@ -0,0 +1,121 @@ +# Media Agent MCP Server + +MCP (Model Context Protocol) server implementation for Scope3 Media Agents. + +## Overview + +This MCP server provides tools to interact with media agents that implement the Media Agent Protocol. Media agents are autonomous systems that optimize media buying on behalf of advertisers. + +## Installation + +```bash +npm install @scope3/agentic-client +``` + +## Usage + +### As a Standalone Server + +Set environment variables and run: + +```bash +export MEDIA_AGENT_URL=https://your-media-agent.example.com +export MEDIA_AGENT_API_KEY=your_api_key +npx scope3-media-agent +``` + +### Programmatically + +```typescript +import { MediaAgentMCP } from '@scope3/agentic-client'; + +const server = new MediaAgentMCP({ + mediaAgentUrl: 'https://your-media-agent.example.com', + apiKey: process.env.MEDIA_AGENT_API_KEY, + name: 'my-media-agent', + version: '1.0.0', +}); + +await server.run(); +``` + +## Available Tools + +### `get_proposed_tactics` + +Get tactic proposals from the media agent. Called when setting up a campaign. + +**Parameters:** +- `campaignId` (required): Campaign ID +- `seatId` (required): Seat/account ID +- `budgetRange`: Budget range with min/max/currency +- `startDate`: Campaign start date (ISO 8601) +- `endDate`: Campaign end date (ISO 8601) +- `channels`: Array of channels (display, video, audio, native, ctv) +- `countries`: Array of ISO country codes +- `objectives`: Campaign objectives +- `brief`: Campaign brief text +- `acceptedPricingMethods`: Accepted pricing methods + +**Returns:** List of proposed tactics with execution plans, budget capacity, and pricing + +### `manage_tactic` + +Accept or decline tactic assignment. + +**Parameters:** +- `tacticId` (required): ID of the tactic +- `tacticContext` (required): Complete tactic details +- `brandAgentId` (required): Brand agent ID +- `seatId` (required): Seat/account ID +- `customFields`: Custom fields from advertiser + +**Returns:** Acknowledgment of assignment + +### `tactic_context_updated` + +Notification of tactic changes (budget, schedule, etc.). + +**Parameters:** +- `tacticId` (required): Tactic ID +- `tactic` (required): Current tactic state +- `patch` (required): JSON Patch format changes + +### `tactic_creatives_updated` + +Notification of creative changes. + +**Parameters:** +- `tacticId` (required): Tactic ID +- `creatives` (required): Updated creative assets +- `patch` (required): JSON Patch format changes + +### `tactic_feedback` + +Performance feedback from the orchestrator. + +**Parameters:** +- `tacticId` (required): Tactic ID +- `startDate` (required): Start of feedback interval +- `endDate` (required): End of feedback interval +- `deliveryIndex` (required): Delivery performance (100 = on target) +- `performanceIndex` (required): Performance vs target (100 = maximum) + +## Configuration + +### Environment Variables + +- `MEDIA_AGENT_URL`: URL of your media agent server (required) +- `MEDIA_AGENT_API_KEY`: API key for authentication (optional) + +## Media Agent Protocol + +The MCP server communicates with media agents that implement the [Media Agent Protocol](https://docs.agentic.scope3.com/media-agent-protocol). Your media agent must implement these endpoints: + +- `POST /get-proposed-tactics` +- `POST /manage-tactic` +- `POST /tactic-context-updated` +- `POST /tactic-creatives-updated` +- `POST /tactic-feedback` + +See the OpenAPI specification in `media-agent-openapi.yaml` for full details. diff --git a/examples/media-agent-mcp.ts b/examples/media-agent-mcp.ts new file mode 100644 index 0000000..5f254d0 --- /dev/null +++ b/examples/media-agent-mcp.ts @@ -0,0 +1,16 @@ +import { MediaAgentMCP } from '../src/media-agent-mcp'; + +// Example: Running the media agent MCP server +// This shows how to programmatically create and run the MCP server + +const server = new MediaAgentMCP({ + mediaAgentUrl: 'https://your-media-agent.example.com', + apiKey: process.env.MEDIA_AGENT_API_KEY, + name: 'example-media-agent', + version: '1.0.0', +}); + +server.run().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/examples/simple-media-agent.ts b/examples/simple-media-agent.ts new file mode 100644 index 0000000..33021e7 --- /dev/null +++ b/examples/simple-media-agent.ts @@ -0,0 +1,95 @@ +import express from 'express'; +import type { Request, Response } from 'express'; + +/** + * Example: Simple Media Agent Implementation + * + * This demonstrates a basic media agent that implements the Media Agent Protocol. + * In production, you would add your optimization logic, data analysis, etc. + */ + +const app = express(); +app.use(express.json()); + +// POST /get-proposed-tactics +app.post('/get-proposed-tactics', (req: Request, res: Response) => { + const { budgetRange } = req.body; + + // In production, analyze the campaign and propose tactics + const proposedTactics = [ + { + tacticId: 'premium-display-tactic', + execution: 'Target premium display inventory with 85% viewability', + budgetCapacity: budgetRange?.max ? budgetRange.max * 0.5 : 50000, + pricing: { + method: 'revshare', + rate: 0.15, + currency: 'USD', + }, + sku: 'premium-display', + customFieldsRequired: [ + { + fieldName: 'targetVCPM', + fieldType: 'number', + description: 'Target viewable CPM in USD', + }, + ], + }, + ]; + + res.json({ proposedTactics }); +}); + +// POST /manage-tactic +app.post('/manage-tactic', (req: Request, res: Response) => { + const { tacticId, tacticContext, customFields } = req.body; + + console.log(`Managing tactic ${tacticId}`, { tacticContext, customFields }); + + // In production, set up targeting, create media buys, etc. + + res.json({ + acknowledged: true, + }); +}); + +// POST /tactic-context-updated +app.post('/tactic-context-updated', (req: Request, res: Response) => { + const { tacticId, patch } = req.body; + + console.log(`Tactic ${tacticId} updated:`, patch); + + // In production, adjust media buys based on changes + + res.json({ acknowledged: true }); +}); + +// POST /tactic-creatives-updated +app.post('/tactic-creatives-updated', (req: Request, res: Response) => { + const { tacticId, patch } = req.body; + + console.log(`Creatives for tactic ${tacticId} updated:`, patch); + + // In production, update media buys with new creatives + + res.json({ acknowledged: true }); +}); + +// POST /tactic-feedback +app.post('/tactic-feedback', (req: Request, res: Response) => { + const { tacticId, deliveryIndex, performanceIndex } = req.body; + + console.log(`Feedback for tactic ${tacticId}:`, { + deliveryIndex, + performanceIndex, + }); + + // In production, optimize based on feedback + + res.json({ acknowledged: true }); +}); + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`Simple media agent listening on port ${PORT}`); +}); diff --git a/media-agent-openapi.yaml b/media-agent-openapi.yaml new file mode 100644 index 0000000..00bb81c --- /dev/null +++ b/media-agent-openapi.yaml @@ -0,0 +1,784 @@ +openapi: 3.0.0 +info: + title: Media Agent Protocol + version: 1.0.0 + description: | + Protocol specification for media agents to implement on their own infrastructure. + + Media agents are autonomous systems that optimize media buying on behalf of advertisers. + When you build a media agent, you implement these endpoints on YOUR servers, and Scope3's + platform will call them to get tactic proposals, assign tactics, and send updates. + + This is similar to how sales agents work - you implement the protocol on your infrastructure, + not in Scope3's codebase. + +servers: + - url: https://media-agent.yourcompany.com + description: Your media agent server (you implement this) + +tags: + - name: Tactic Proposals + description: Endpoints Scope3 calls to get your tactic proposals + - name: Tactic Management + description: Endpoints for managing assigned tactics + - name: Tactic Updates + description: Notifications about changes to tactics you're managing + +paths: + /get-proposed-tactics: + post: + tags: [Tactic Proposals] + summary: Get tactic proposals from your agent + operationId: get_proposed_tactics + description: | + Scope3 calls this endpoint when setting up a campaign to ask what tactics + your agent can handle and how you would approach execution. + + Analyze the campaign and respond with proposed tactics, budget capacity, + and your pricing model. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GetProposedTacticsRequest' + responses: + '200': + description: Tactic proposals from your agent + content: + application/json: + schema: + $ref: '#/components/schemas/GetProposedTacticsResponse' + '400': + description: Invalid request + '500': + description: Internal error + + /manage-tactic: + post: + tags: [Tactic Management] + summary: Accept or decline tactic assignment + operationId: manage_tactic + description: | + Scope3 calls this when your agent is assigned to manage a tactic. + You should acknowledge and begin setup, or decline if you can't fulfill it. + + The tactic context contains everything you need: budget, schedule, + targeting constraints, creatives, and any custom fields. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ManageTacticRequest' + responses: + '200': + description: Acknowledgment of tactic assignment + content: + application/json: + schema: + $ref: '#/components/schemas/ManageTacticResponse' + '400': + description: Invalid request + + /tactic-context-updated: + post: + tags: [Tactic Updates] + summary: Notification of tactic changes + operationId: tactic_context_updated + description: | + Scope3 calls this when a tactic is modified by the user or their agent. + Changes may include budget adjustments, schedule changes, or other updates. + + Your agent MUST handle these changes as they may impact targeting, + delivery, or budget allocation. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TacticContextUpdatedRequest' + responses: + '200': + description: Acknowledged + '400': + description: Invalid request + + /tactic-creatives-updated: + post: + tags: [Tactic Updates] + summary: Notification of creative changes + operationId: tactic_creatives_updated + description: | + Scope3 calls this when creatives are added, removed, or modified for a tactic. + + Update your media buys to use the new creative assets. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TacticCreativesUpdatedRequest' + responses: + '200': + description: Acknowledged + '400': + description: Invalid request + + /tactic-feedback: + post: + tags: [Tactic Updates] + summary: Performance feedback from orchestrator + operationId: tactic_feedback + description: | + Scope3 sends performance feedback to help you optimize delivery. + + - deliveryIndex: 100 = on target, <100 = under-delivering, >100 = over-delivering + - performanceIndex: 100 = maximum, relative to target or other tactics + + Your agent MAY use this to adjust targeting, budget allocation, or other settings. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TacticFeedbackRequest' + responses: + '200': + description: Acknowledged + '400': + description: Invalid request + +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-API-Key + description: API key for authenticating Scope3's requests to your server + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT bearer token for OAuth-based authentication + basic: + type: http + scheme: basic + description: Basic authentication with username/password + + schemas: + BudgetRange: + type: object + description: Budget range for campaign planning (buyer typically won't reveal full budget) + properties: + min: + type: number + description: Minimum budget available + example: 50000 + max: + type: number + description: Maximum budget available + example: 150000 + currency: + type: string + description: Currency for budget (ISO 4217 code) + default: USD + example: USD + + TacticPricing: + type: object + required: + - method + - rate + properties: + method: + type: string + enum: [passthrough, revshare, cost_per_unit] + description: How the media agent charges for this tactic (passthrough, revshare, or cost_per_unit) + example: revshare + rate: + type: number + description: Rate for the pricing method (e.g., 0.15 for 15% revshare, 2.50 for $2.50 CPM) + example: 0.15 + currency: + type: string + description: Currency for pricing (ISO 4217 code) + default: USD + example: USD + + CustomField: + type: object + properties: + fieldName: + type: string + description: Name of the custom field + example: targetVCPM + fieldType: + type: string + enum: [string, number, boolean, array, object] + description: Data type of the field + example: number + description: + type: string + description: Help text explaining what this field does + example: Target vCPM in USD + + GetProposedTacticsRequest: + type: object + required: + - campaignId + - seatId + properties: + campaignId: + type: string + description: Campaign ID + example: camp_123 + budgetRange: + $ref: '#/components/schemas/BudgetRange' + startDate: + type: string + format: date-time + description: Campaign start date in UTC (ISO 8601 format) + example: '2025-01-01T00:00:00Z' + endDate: + type: string + format: date-time + description: Campaign end date in UTC (ISO 8601 format) + example: '2025-01-31T23:59:59Z' + channels: + type: array + items: + type: string + enum: [display, video, audio, native, ctv] + description: Advertising channels (aligned with AdCP channel schema) + example: [display, video] + countries: + type: array + items: + type: string + description: ISO 3166-1 alpha-2 country codes + example: [US, CA] + objectives: + type: array + items: + type: string + description: Campaign objectives/outcomes (e.g., awareness, consideration, conversion) + example: [awareness, consideration] + brief: + type: string + description: Campaign brief text + example: Launch campaign for new product... + acceptedPricingMethods: + type: array + items: + type: string + enum: [cpm, vcpm, cpc, cpcv, cpv, cpp, flat_rate] + description: AdCP pricing models acceptable to the buyer for sales agent pricing + example: [cpm, vcpm, flat_rate] + promotedOfferings: + $ref: '#/components/schemas/PromotedOfferings' + seatId: + type: string + description: Seat/account ID for this request + example: seat_456 + + ProposedTactic: + type: object + required: + - tacticId + - execution + - budgetCapacity + - pricing + properties: + tacticId: + type: string + description: Unique identifier for this proposed tactic (you generate this) + example: premium-vcpm-display + execution: + type: string + description: How you would execute this tactic + example: Target premium inventory at $2.50 vCPM with 85% viewability + budgetCapacity: + type: number + description: Maximum budget you can effectively manage + example: 50000 + pricing: + $ref: '#/components/schemas/TacticPricing' + sku: + type: string + description: Identifier for this tactic type + example: premium-vcpm + customFieldsRequired: + type: array + items: + $ref: '#/components/schemas/CustomField' + description: Custom fields needed to execute this tactic + + GetProposedTacticsResponse: + type: object + properties: + proposedTactics: + type: array + items: + $ref: '#/components/schemas/ProposedTactic' + description: List of tactics you can handle (empty array if none) + + TacticContext: + type: object + properties: + budget: + type: number + description: Budget allocated + example: 50000 + budgetCurrency: + type: string + description: Currency for budget (ISO 4217 code) + default: USD + example: USD + startDate: + type: string + format: date-time + description: Tactic start date in UTC (ISO 8601 format) + example: '2025-01-01T00:00:00Z' + endDate: + type: string + format: date-time + description: Tactic end date in UTC (ISO 8601 format) + example: '2025-01-31T23:59:59Z' + channel: + type: string + enum: [display, video, audio, native, ctv] + description: Advertising channel (aligned with AdCP channel schema) + example: display + countries: + type: array + items: + type: string + description: Target countries + example: [US] + creatives: + type: array + items: + $ref: '#/components/schemas/Creative' + description: Creative assets to use (uses Creative from main schema) + brandStandards: + type: array + items: + $ref: '#/components/schemas/BrandStandard' + description: Brand safety and suitability requirements (uses BrandStandard from main schema) + + ManageTacticRequest: + type: object + required: + - tacticId + - tacticContext + - brandAgentId + - seatId + properties: + tacticId: + type: string + description: ID of the tactic (matches one you proposed) + example: premium-vcpm-display + tacticContext: + $ref: '#/components/schemas/TacticContext' + brandAgentId: + type: string + description: Brand agent (advertiser) for this campaign + example: ba_123 + seatId: + type: string + description: Seat/account ID + example: seat_456 + customFields: + type: object + description: Custom fields provided by advertiser + example: { targetVCPM: 2.5 } + + ManageTacticResponse: + type: object + required: + - acknowledged + properties: + acknowledged: + type: boolean + description: true to accept assignment, false to decline + example: true + reason: + type: string + description: Optional reason if declining + example: Insufficient budget for effective optimization + + PatchOperation: + type: object + properties: + op: + type: string + enum: [add, remove, replace] + description: Patch operation type + example: replace + path: + type: string + description: JSON Pointer to changed field + example: /budget + value: + description: New value for the field + + TacticContextUpdatedRequest: + type: object + required: + - tacticId + - tactic + - patch + properties: + tacticId: + type: string + description: Tactic ID + example: premium-vcpm-display + tactic: + type: object + description: Current tactic state (after changes) + patch: + type: array + items: + $ref: '#/components/schemas/PatchOperation' + description: Changes in JSON Patch format (RFC 6902) + + TacticCreativesUpdatedRequest: + type: object + required: + - tacticId + - creatives + - patch + properties: + tacticId: + type: string + description: Tactic ID + example: premium-vcpm-display + creatives: + type: array + items: + $ref: '#/components/schemas/Creative' + description: Updated creative assets + patch: + type: array + items: + $ref: '#/components/schemas/PatchOperation' + description: Changes to creatives array in JSON Patch format + + Creative: + type: object + required: + - creativeId + - name + - status + - createdAt + - updatedAt + properties: + creativeId: + type: string + description: Unique identifier for the creative + example: cr_001 + name: + type: string + description: Name of the creative + example: Summer Campaign Banner + status: + type: string + description: Status of the creative + example: ACTIVE + campaignId: + type: string + description: Campaign this creative belongs to (optional) + example: camp_123 + createdAt: + type: string + format: date-time + description: When the creative was created (ISO 8601 UTC) + example: '2025-01-01T00:00:00Z' + updatedAt: + type: string + format: date-time + description: When the creative was last updated (ISO 8601 UTC) + example: '2025-01-15T14:30:00Z' + + BrandStandard: + type: object + required: + - id + - name + - countryCodes + - channelCodes + - brands + - createdAt + - updatedAt + properties: + id: + type: string + description: Unique identifier for the brand standard + example: bs_001 + name: + type: string + description: Name of the brand standard + example: Premium Brand Safety + description: + type: string + description: Description of the standard (optional) + example: High viewability requirements for premium inventory + countryCodes: + type: array + items: + type: string + description: ISO 3166-1 alpha-2 country codes this standard applies to + example: [US, CA] + channelCodes: + type: array + items: + type: string + description: Channels this standard applies to + example: [display, video] + brands: + type: array + items: + type: string + description: Brand names this standard applies to + example: [Brand A, Brand B] + createdAt: + type: string + format: date-time + description: When the standard was created (ISO 8601 UTC) + example: '2025-01-01T00:00:00Z' + updatedAt: + type: string + format: date-time + description: When the standard was last updated (ISO 8601 UTC) + example: '2025-01-15T14:30:00Z' + + TacticFeedbackRequest: + type: object + required: + - tacticId + - startDate + - endDate + - deliveryIndex + - performanceIndex + properties: + tacticId: + type: string + description: Tactic ID + example: premium-vcpm-display + startDate: + type: string + format: date-time + description: Start of feedback interval in UTC (ISO 8601 format) + example: '2025-01-01T00:00:00Z' + endDate: + type: string + format: date-time + description: End of feedback interval in UTC (ISO 8601 format) + example: '2025-01-07T23:59:59Z' + deliveryIndex: + type: number + description: Delivery performance (100 = on target) + example: 95 + performanceIndex: + type: number + description: Performance vs target or peers (100 = maximum) + example: 110 + + PromotedOfferings: + type: object + description: Complete offering specification combining brand manifest, product selectors, and inline offerings (AdCP spec) + required: + - brand_manifest + properties: + brand_manifest: + oneOf: + - $ref: '#/components/schemas/BrandManifest' + - type: string + description: URL reference to a hosted brand manifest + example: https://brand.example.com/manifest.json + description: Brand information manifest (inline object or URL reference) + product_selectors: + type: object + description: Optional product catalog selectors + properties: + product_ids: + type: array + items: + type: string + description: Specific product IDs to promote + example: [prod_123, prod_456] + offerings: + type: array + description: Inline offerings for campaigns without a product catalog + items: + type: object + required: + - name + properties: + name: + type: string + description: Offering name + example: Winter Sale + description: + type: string + description: Description of what's being offered + example: 20% off all winter products + assets: + type: array + description: Assets specific to this offering + items: + type: object + + BrandManifest: + type: object + description: Brand information manifest containing assets, themes, and guidelines (AdCP spec) + properties: + url: + type: string + format: uri + description: Primary brand URL for context and asset discovery + example: https://brand.example.com + name: + type: string + description: Brand or business name + example: Acme Corporation + logos: + type: array + description: Brand logo assets with semantic tags + items: + type: object + required: + - url + properties: + url: + type: string + format: uri + description: URL to the logo asset + example: https://cdn.example.com/logo.png + tags: + type: array + items: + type: string + description: Semantic tags (e.g., 'dark', 'light', 'square', 'horizontal', 'icon') + example: [dark, horizontal] + width: + type: number + description: Logo width in pixels + example: 200 + height: + type: number + description: Logo height in pixels + example: 50 + colors: + type: object + description: Brand color palette + properties: + primary: + type: string + description: Primary brand color (hex) + example: '#0D9373' + secondary: + type: string + description: Secondary brand color (hex) + example: '#07C983' + accent: + type: string + description: Accent color (hex) + background: + type: string + description: Background color (hex) + text: + type: string + description: Text color (hex) + fonts: + type: object + description: Brand typography guidelines + properties: + primary: + type: string + description: Primary font family name + example: Inter + secondary: + type: string + description: Secondary font family name + example: Georgia + font_urls: + type: array + items: + type: string + format: uri + description: URLs to web font files if using custom fonts + tone: + type: string + description: Brand voice and messaging tone + example: professional + tagline: + type: string + description: Brand tagline or slogan + example: Innovation that moves you forward + product_catalog: + type: object + description: Product catalog information for e-commerce advertisers (enables SKU-level creative generation) + required: + - feed_url + properties: + feed_url: + type: string + format: uri + description: URL to product catalog feed + example: https://brand.example.com/products.xml + feed_format: + type: string + enum: [google_merchant_center, facebook_catalog, custom] + description: Format of the product feed + categories: + type: array + items: + type: string + description: Product categories available in the catalog + example: [electronics, apparel, home_goods] + last_updated: + type: string + format: date-time + description: When the product catalog was last updated + update_frequency: + type: string + enum: [realtime, hourly, daily, weekly] + description: How frequently the product catalog is updated + industry: + type: string + description: Industry or vertical + example: retail + target_audience: + type: string + description: Primary target audience description + example: Tech-savvy millennials aged 25-40 + disclaimers: + type: array + description: Legal disclaimers or required text that must appear in creatives + items: + type: object + required: + - text + properties: + text: + type: string + description: Disclaimer text + context: + type: string + description: When this disclaimer applies (e.g., financial_products, all) + required: + type: boolean + description: Whether this disclaimer must appear + contact: + type: object + description: Brand contact information + properties: + email: + type: string + format: email + description: Contact email + phone: + type: string + description: Contact phone number diff --git a/package-lock.json b/package-lock.json index 56a5aa3..bf46816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@modelcontextprotocol/sdk": "^1.20.1", "express": "^4.18.0" }, + "bin": { + "scope3-media-agent": "dist/media-agent-server.js" + }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.7", diff --git a/package.json b/package.json index 648238f..62ea9d0 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "TypeScript client for the Scope3 Agentic API with AdCP webhook support", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "scope3-media-agent": "dist/media-agent-server.js" + }, "scripts": { "build": "npm run type-check && tsc", "dev": "tsc --watch", @@ -12,6 +15,7 @@ "format": "prettier --write \"src/**/*.ts\"", "type-check": "tsc --noEmit", "generate-types": "openapi-typescript openapi.yaml -o src/types/api.ts", + "generate-media-agent-types": "openapi-typescript media-agent-openapi.yaml -o src/types/media-agent-api.ts", "update-schemas": "curl -f -o openapi.yaml https://docs.agentic.scope3.com/openapi.yaml && npm run generate-types", "prepare": "husky", "pretest": "npm run type-check", diff --git a/src/__tests__/media-agent-mcp.test.ts b/src/__tests__/media-agent-mcp.test.ts new file mode 100644 index 0000000..d76df4c --- /dev/null +++ b/src/__tests__/media-agent-mcp.test.ts @@ -0,0 +1,22 @@ +import { MediaAgentMCP } from '../media-agent-mcp'; + +describe('MediaAgentMCP', () => { + it('should create an instance with required config', () => { + const mcp = new MediaAgentMCP({ + mediaAgentUrl: 'https://example.com', + }); + + expect(mcp).toBeInstanceOf(MediaAgentMCP); + }); + + it('should create an instance with full config', () => { + const mcp = new MediaAgentMCP({ + mediaAgentUrl: 'https://example.com', + apiKey: 'test-key', + name: 'test-agent', + version: '1.0.0', + }); + + expect(mcp).toBeInstanceOf(MediaAgentMCP); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6d4e7fe..e0992e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,10 @@ export { Scope3AgenticClient } from './sdk'; // Legacy export for backwards compatibility export { Scope3AgenticClient as Scope3SDK } from './sdk'; export { WebhookServer } from './webhook-server'; +export { MediaAgentMCP } from './media-agent-mcp'; export type { ClientConfig, ToolResponse, Environment } from './types'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; +export type { MediaAgentMCPConfig } from './media-agent-mcp'; export * from './resources/assets'; export * from './resources/brand-agents'; diff --git a/src/media-agent-mcp.ts b/src/media-agent-mcp.ts new file mode 100644 index 0000000..ce64acb --- /dev/null +++ b/src/media-agent-mcp.ts @@ -0,0 +1,354 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import type { components } from './types/media-agent-api.js'; + +type GetProposedTacticsRequest = components['schemas']['GetProposedTacticsRequest']; +type GetProposedTacticsResponse = components['schemas']['GetProposedTacticsResponse']; +type ManageTacticRequest = components['schemas']['ManageTacticRequest']; +type ManageTacticResponse = components['schemas']['ManageTacticResponse']; +type TacticContextUpdatedRequest = components['schemas']['TacticContextUpdatedRequest']; +type TacticCreativesUpdatedRequest = components['schemas']['TacticCreativesUpdatedRequest']; +type TacticFeedbackRequest = components['schemas']['TacticFeedbackRequest']; + +export interface MediaAgentMCPConfig { + name?: string; + version?: string; + mediaAgentUrl: string; + apiKey?: string; +} + +export class MediaAgentMCP { + private server: Server; + private config: MediaAgentMCPConfig; + + constructor(config: MediaAgentMCPConfig) { + this.config = config; + this.server = new Server( + { + name: config.name || 'media-agent-mcp', + version: config.version || '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: this.getTools(), + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + switch (name) { + case 'get_proposed_tactics': + return this.handleGetProposedTactics(args as GetProposedTacticsRequest); + case 'manage_tactic': + return this.handleManageTactic(args as ManageTacticRequest); + case 'tactic_context_updated': + return this.handleTacticContextUpdated(args as TacticContextUpdatedRequest); + case 'tactic_creatives_updated': + return this.handleTacticCreativesUpdated(args as TacticCreativesUpdatedRequest); + case 'tactic_feedback': + return this.handleTacticFeedback(args as TacticFeedbackRequest); + default: + throw new Error(`Unknown tool: ${name}`); + } + }); + } + + private getTools(): Tool[] { + return [ + { + name: 'get_proposed_tactics', + description: + 'Get tactic proposals from the media agent. Called when setting up a campaign to ask what tactics the agent can handle.', + inputSchema: { + type: 'object', + properties: { + campaignId: { + type: 'string', + description: 'Campaign ID', + }, + budgetRange: { + type: 'object', + properties: { + min: { type: 'number' }, + max: { type: 'number' }, + currency: { type: 'string' }, + }, + }, + startDate: { + type: 'string', + description: 'Campaign start date (ISO 8601)', + }, + endDate: { + type: 'string', + description: 'Campaign end date (ISO 8601)', + }, + channels: { + type: 'array', + items: { + type: 'string', + enum: ['display', 'video', 'audio', 'native', 'ctv'], + }, + }, + countries: { + type: 'array', + items: { type: 'string' }, + description: 'ISO 3166-1 alpha-2 country codes', + }, + objectives: { + type: 'array', + items: { type: 'string' }, + }, + brief: { + type: 'string', + }, + acceptedPricingMethods: { + type: 'array', + items: { + type: 'string', + enum: ['cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'flat_rate'], + }, + }, + seatId: { + type: 'string', + description: 'Seat/account ID', + }, + }, + required: ['campaignId', 'seatId'], + }, + }, + { + name: 'manage_tactic', + description: + 'Accept or decline tactic assignment. Called when the agent is assigned to manage a tactic.', + inputSchema: { + type: 'object', + properties: { + tacticId: { + type: 'string', + description: 'ID of the tactic', + }, + tacticContext: { + type: 'object', + description: 'Complete tactic details', + }, + brandAgentId: { + type: 'string', + description: 'Brand agent ID', + }, + seatId: { + type: 'string', + description: 'Seat/account ID', + }, + customFields: { + type: 'object', + description: 'Custom fields from advertiser', + }, + }, + required: ['tacticId', 'tacticContext', 'brandAgentId', 'seatId'], + }, + }, + { + name: 'tactic_context_updated', + description: + 'Notification of tactic changes. MUST be handled as changes may impact targeting or budget.', + inputSchema: { + type: 'object', + properties: { + tacticId: { + type: 'string', + description: 'Tactic ID', + }, + tactic: { + type: 'object', + description: 'Current tactic state', + }, + patch: { + type: 'array', + items: { + type: 'object', + properties: { + op: { type: 'string', enum: ['add', 'remove', 'replace'] }, + path: { type: 'string' }, + value: {}, + }, + }, + }, + }, + required: ['tacticId', 'tactic', 'patch'], + }, + }, + { + name: 'tactic_creatives_updated', + description: + 'Notification of creative changes. Update media buys to use new creative assets.', + inputSchema: { + type: 'object', + properties: { + tacticId: { + type: 'string', + description: 'Tactic ID', + }, + creatives: { + type: 'array', + description: 'Updated creative assets', + }, + patch: { + type: 'array', + items: { + type: 'object', + properties: { + op: { type: 'string', enum: ['add', 'remove', 'replace'] }, + path: { type: 'string' }, + value: {}, + }, + }, + }, + }, + required: ['tacticId', 'creatives', 'patch'], + }, + }, + { + name: 'tactic_feedback', + description: + 'Performance feedback from orchestrator. MAY trigger updates to improve performance.', + inputSchema: { + type: 'object', + properties: { + tacticId: { + type: 'string', + description: 'Tactic ID', + }, + startDate: { + type: 'string', + description: 'Start of feedback interval (ISO 8601)', + }, + endDate: { + type: 'string', + description: 'End of feedback interval (ISO 8601)', + }, + deliveryIndex: { + type: 'number', + description: 'Delivery performance (100 = on target)', + }, + performanceIndex: { + type: 'number', + description: 'Performance vs target (100 = maximum)', + }, + }, + required: ['tacticId', 'startDate', 'endDate', 'deliveryIndex', 'performanceIndex'], + }, + }, + ]; + } + + private async callMediaAgent(endpoint: string, body: unknown): Promise { + const url = `${this.config.mediaAgentUrl}${endpoint}`; + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.config.apiKey) { + headers['X-API-Key'] = this.config.apiKey; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Media agent request failed: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + private async handleGetProposedTactics(args: GetProposedTacticsRequest) { + const response = (await this.callMediaAgent( + '/get-proposed-tactics', + args + )) as GetProposedTacticsResponse; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } + + private async handleManageTactic(args: ManageTacticRequest) { + const response = (await this.callMediaAgent('/manage-tactic', args)) as ManageTacticResponse; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } + + private async handleTacticContextUpdated(args: TacticContextUpdatedRequest) { + await this.callMediaAgent('/tactic-context-updated', args); + + return { + content: [ + { + type: 'text', + text: 'Tactic context update sent successfully', + }, + ], + }; + } + + private async handleTacticCreativesUpdated(args: TacticCreativesUpdatedRequest) { + await this.callMediaAgent('/tactic-creatives-updated', args); + + return { + content: [ + { + type: 'text', + text: 'Tactic creatives update sent successfully', + }, + ], + }; + } + + private async handleTacticFeedback(args: TacticFeedbackRequest) { + await this.callMediaAgent('/tactic-feedback', args); + + return { + content: [ + { + type: 'text', + text: 'Tactic feedback sent successfully', + }, + ], + }; + } + + async run(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} diff --git a/src/media-agent-server.ts b/src/media-agent-server.ts new file mode 100644 index 0000000..b68c4c4 --- /dev/null +++ b/src/media-agent-server.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { MediaAgentMCP } from './media-agent-mcp.js'; + +const mediaAgentUrl = process.env.MEDIA_AGENT_URL; +const apiKey = process.env.MEDIA_AGENT_API_KEY; + +if (!mediaAgentUrl) { + console.error('Error: MEDIA_AGENT_URL environment variable is required'); + process.exit(1); +} + +const server = new MediaAgentMCP({ + mediaAgentUrl, + apiKey, + name: 'scope3-media-agent', + version: '1.0.0', +}); + +server.run().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/types/media-agent-api.ts b/src/types/media-agent-api.ts new file mode 100644 index 0000000..c7c02cf --- /dev/null +++ b/src/types/media-agent-api.ts @@ -0,0 +1,854 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/get-proposed-tactics': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get tactic proposals from your agent + * @description Scope3 calls this endpoint when setting up a campaign to ask what tactics + * your agent can handle and how you would approach execution. + * + * Analyze the campaign and respond with proposed tactics, budget capacity, + * and your pricing model. + */ + post: operations['get_proposed_tactics']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/manage-tactic': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept or decline tactic assignment + * @description Scope3 calls this when your agent is assigned to manage a tactic. + * You should acknowledge and begin setup, or decline if you can't fulfill it. + * + * The tactic context contains everything you need: budget, schedule, + * targeting constraints, creatives, and any custom fields. + */ + post: operations['manage_tactic']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/tactic-context-updated': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Notification of tactic changes + * @description Scope3 calls this when a tactic is modified by the user or their agent. + * Changes may include budget adjustments, schedule changes, or other updates. + * + * Your agent MUST handle these changes as they may impact targeting, + * delivery, or budget allocation. + */ + post: operations['tactic_context_updated']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/tactic-creatives-updated': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Notification of creative changes + * @description Scope3 calls this when creatives are added, removed, or modified for a tactic. + * + * Update your media buys to use the new creative assets. + */ + post: operations['tactic_creatives_updated']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/tactic-feedback': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Performance feedback from orchestrator + * @description Scope3 sends performance feedback to help you optimize delivery. + * + * - deliveryIndex: 100 = on target, <100 = under-delivering, >100 = over-delivering + * - performanceIndex: 100 = maximum, relative to target or other tactics + * + * Your agent MAY use this to adjust targeting, budget allocation, or other settings. + */ + post: operations['tactic_feedback']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description Budget range for campaign planning (buyer typically won't reveal full budget) */ + BudgetRange: { + /** + * @description Minimum budget available + * @example 50000 + */ + min?: number; + /** + * @description Maximum budget available + * @example 150000 + */ + max?: number; + /** + * @description Currency for budget (ISO 4217 code) + * @default USD + * @example USD + */ + currency: string; + }; + TacticPricing: { + /** + * @description How the media agent charges for this tactic (passthrough, revshare, or cost_per_unit) + * @example revshare + * @enum {string} + */ + method: 'passthrough' | 'revshare' | 'cost_per_unit'; + /** + * @description Rate for the pricing method (e.g., 0.15 for 15% revshare, 2.50 for $2.50 CPM) + * @example 0.15 + */ + rate: number; + /** + * @description Currency for pricing (ISO 4217 code) + * @default USD + * @example USD + */ + currency: string; + }; + CustomField: { + /** + * @description Name of the custom field + * @example targetVCPM + */ + fieldName?: string; + /** + * @description Data type of the field + * @example number + * @enum {string} + */ + fieldType?: 'string' | 'number' | 'boolean' | 'array' | 'object'; + /** + * @description Help text explaining what this field does + * @example Target vCPM in USD + */ + description?: string; + }; + GetProposedTacticsRequest: { + /** + * @description Campaign ID + * @example camp_123 + */ + campaignId: string; + budgetRange?: components['schemas']['BudgetRange']; + /** + * Format: date-time + * @description Campaign start date in UTC (ISO 8601 format) + * @example 2025-01-01T00:00:00Z + */ + startDate?: string; + /** + * Format: date-time + * @description Campaign end date in UTC (ISO 8601 format) + * @example 2025-01-31T23:59:59Z + */ + endDate?: string; + /** + * @description Advertising channels (aligned with AdCP channel schema) + * @example [ + * "display", + * "video" + * ] + */ + channels?: ('display' | 'video' | 'audio' | 'native' | 'ctv')[]; + /** + * @description ISO 3166-1 alpha-2 country codes + * @example [ + * "US", + * "CA" + * ] + */ + countries?: string[]; + /** + * @description Campaign objectives/outcomes (e.g., awareness, consideration, conversion) + * @example [ + * "awareness", + * "consideration" + * ] + */ + objectives?: string[]; + /** + * @description Campaign brief text + * @example Launch campaign for new product... + */ + brief?: string; + /** + * @description AdCP pricing models acceptable to the buyer for sales agent pricing + * @example [ + * "cpm", + * "vcpm", + * "flat_rate" + * ] + */ + acceptedPricingMethods?: ('cpm' | 'vcpm' | 'cpc' | 'cpcv' | 'cpv' | 'cpp' | 'flat_rate')[]; + promotedOfferings?: components['schemas']['PromotedOfferings']; + /** + * @description Seat/account ID for this request + * @example seat_456 + */ + seatId: string; + }; + ProposedTactic: { + /** + * @description Unique identifier for this proposed tactic (you generate this) + * @example premium-vcpm-display + */ + tacticId: string; + /** + * @description How you would execute this tactic + * @example Target premium inventory at $2.50 vCPM with 85% viewability + */ + execution: string; + /** + * @description Maximum budget you can effectively manage + * @example 50000 + */ + budgetCapacity: number; + pricing: components['schemas']['TacticPricing']; + /** + * @description Identifier for this tactic type + * @example premium-vcpm + */ + sku?: string; + /** @description Custom fields needed to execute this tactic */ + customFieldsRequired?: components['schemas']['CustomField'][]; + }; + GetProposedTacticsResponse: { + /** @description List of tactics you can handle (empty array if none) */ + proposedTactics?: components['schemas']['ProposedTactic'][]; + }; + TacticContext: { + /** + * @description Budget allocated + * @example 50000 + */ + budget?: number; + /** + * @description Currency for budget (ISO 4217 code) + * @default USD + * @example USD + */ + budgetCurrency: string; + /** + * Format: date-time + * @description Tactic start date in UTC (ISO 8601 format) + * @example 2025-01-01T00:00:00Z + */ + startDate?: string; + /** + * Format: date-time + * @description Tactic end date in UTC (ISO 8601 format) + * @example 2025-01-31T23:59:59Z + */ + endDate?: string; + /** + * @description Advertising channel (aligned with AdCP channel schema) + * @example display + * @enum {string} + */ + channel?: 'display' | 'video' | 'audio' | 'native' | 'ctv'; + /** + * @description Target countries + * @example [ + * "US" + * ] + */ + countries?: string[]; + /** @description Creative assets to use (uses Creative from main schema) */ + creatives?: components['schemas']['Creative'][]; + /** @description Brand safety and suitability requirements (uses BrandStandard from main schema) */ + brandStandards?: components['schemas']['BrandStandard'][]; + }; + ManageTacticRequest: { + /** + * @description ID of the tactic (matches one you proposed) + * @example premium-vcpm-display + */ + tacticId: string; + tacticContext: components['schemas']['TacticContext']; + /** + * @description Brand agent (advertiser) for this campaign + * @example ba_123 + */ + brandAgentId: string; + /** + * @description Seat/account ID + * @example seat_456 + */ + seatId: string; + /** + * @description Custom fields provided by advertiser + * @example { + * "targetVCPM": 2.5 + * } + */ + customFields?: Record; + }; + ManageTacticResponse: { + /** + * @description true to accept assignment, false to decline + * @example true + */ + acknowledged: boolean; + /** + * @description Optional reason if declining + * @example Insufficient budget for effective optimization + */ + reason?: string; + }; + PatchOperation: { + /** + * @description Patch operation type + * @example replace + * @enum {string} + */ + op?: 'add' | 'remove' | 'replace'; + /** + * @description JSON Pointer to changed field + * @example /budget + */ + path?: string; + /** @description New value for the field */ + value?: unknown; + }; + TacticContextUpdatedRequest: { + /** + * @description Tactic ID + * @example premium-vcpm-display + */ + tacticId: string; + /** @description Current tactic state (after changes) */ + tactic: Record; + /** @description Changes in JSON Patch format (RFC 6902) */ + patch: components['schemas']['PatchOperation'][]; + }; + TacticCreativesUpdatedRequest: { + /** + * @description Tactic ID + * @example premium-vcpm-display + */ + tacticId: string; + /** @description Updated creative assets */ + creatives: components['schemas']['Creative'][]; + /** @description Changes to creatives array in JSON Patch format */ + patch: components['schemas']['PatchOperation'][]; + }; + Creative: { + /** + * @description Unique identifier for the creative + * @example cr_001 + */ + creativeId: string; + /** + * @description Name of the creative + * @example Summer Campaign Banner + */ + name: string; + /** + * @description Status of the creative + * @example ACTIVE + */ + status: string; + /** + * @description Campaign this creative belongs to (optional) + * @example camp_123 + */ + campaignId?: string; + /** + * Format: date-time + * @description When the creative was created (ISO 8601 UTC) + * @example 2025-01-01T00:00:00Z + */ + createdAt: string; + /** + * Format: date-time + * @description When the creative was last updated (ISO 8601 UTC) + * @example 2025-01-15T14:30:00Z + */ + updatedAt: string; + }; + BrandStandard: { + /** + * @description Unique identifier for the brand standard + * @example bs_001 + */ + id: string; + /** + * @description Name of the brand standard + * @example Premium Brand Safety + */ + name: string; + /** + * @description Description of the standard (optional) + * @example High viewability requirements for premium inventory + */ + description?: string; + /** + * @description ISO 3166-1 alpha-2 country codes this standard applies to + * @example [ + * "US", + * "CA" + * ] + */ + countryCodes: string[]; + /** + * @description Channels this standard applies to + * @example [ + * "display", + * "video" + * ] + */ + channelCodes: string[]; + /** + * @description Brand names this standard applies to + * @example [ + * "Brand A", + * "Brand B" + * ] + */ + brands: string[]; + /** + * Format: date-time + * @description When the standard was created (ISO 8601 UTC) + * @example 2025-01-01T00:00:00Z + */ + createdAt: string; + /** + * Format: date-time + * @description When the standard was last updated (ISO 8601 UTC) + * @example 2025-01-15T14:30:00Z + */ + updatedAt: string; + }; + TacticFeedbackRequest: { + /** + * @description Tactic ID + * @example premium-vcpm-display + */ + tacticId: string; + /** + * Format: date-time + * @description Start of feedback interval in UTC (ISO 8601 format) + * @example 2025-01-01T00:00:00Z + */ + startDate: string; + /** + * Format: date-time + * @description End of feedback interval in UTC (ISO 8601 format) + * @example 2025-01-07T23:59:59Z + */ + endDate: string; + /** + * @description Delivery performance (100 = on target) + * @example 95 + */ + deliveryIndex: number; + /** + * @description Performance vs target or peers (100 = maximum) + * @example 110 + */ + performanceIndex: number; + }; + /** @description Complete offering specification combining brand manifest, product selectors, and inline offerings (AdCP spec) */ + PromotedOfferings: { + /** @description Brand information manifest (inline object or URL reference) */ + brand_manifest: components['schemas']['BrandManifest'] | string; + /** @description Optional product catalog selectors */ + product_selectors?: { + /** + * @description Specific product IDs to promote + * @example [ + * "prod_123", + * "prod_456" + * ] + */ + product_ids?: string[]; + }; + /** @description Inline offerings for campaigns without a product catalog */ + offerings?: { + /** + * @description Offering name + * @example Winter Sale + */ + name: string; + /** + * @description Description of what's being offered + * @example 20% off all winter products + */ + description?: string; + /** @description Assets specific to this offering */ + assets?: Record[]; + }[]; + }; + /** @description Brand information manifest containing assets, themes, and guidelines (AdCP spec) */ + BrandManifest: { + /** + * Format: uri + * @description Primary brand URL for context and asset discovery + * @example https://brand.example.com + */ + url?: string; + /** + * @description Brand or business name + * @example Acme Corporation + */ + name?: string; + /** @description Brand logo assets with semantic tags */ + logos?: { + /** + * Format: uri + * @description URL to the logo asset + * @example https://cdn.example.com/logo.png + */ + url: string; + /** + * @description Semantic tags (e.g., 'dark', 'light', 'square', 'horizontal', 'icon') + * @example [ + * "dark", + * "horizontal" + * ] + */ + tags?: string[]; + /** + * @description Logo width in pixels + * @example 200 + */ + width?: number; + /** + * @description Logo height in pixels + * @example 50 + */ + height?: number; + }[]; + /** @description Brand color palette */ + colors?: { + /** + * @description Primary brand color (hex) + * @example #0D9373 + */ + primary?: string; + /** + * @description Secondary brand color (hex) + * @example #07C983 + */ + secondary?: string; + /** @description Accent color (hex) */ + accent?: string; + /** @description Background color (hex) */ + background?: string; + /** @description Text color (hex) */ + text?: string; + }; + /** @description Brand typography guidelines */ + fonts?: { + /** + * @description Primary font family name + * @example Inter + */ + primary?: string; + /** + * @description Secondary font family name + * @example Georgia + */ + secondary?: string; + /** @description URLs to web font files if using custom fonts */ + font_urls?: string[]; + }; + /** + * @description Brand voice and messaging tone + * @example professional + */ + tone?: string; + /** + * @description Brand tagline or slogan + * @example Innovation that moves you forward + */ + tagline?: string; + /** @description Product catalog information for e-commerce advertisers (enables SKU-level creative generation) */ + product_catalog?: { + /** + * Format: uri + * @description URL to product catalog feed + * @example https://brand.example.com/products.xml + */ + feed_url: string; + /** + * @description Format of the product feed + * @enum {string} + */ + feed_format?: 'google_merchant_center' | 'facebook_catalog' | 'custom'; + /** + * @description Product categories available in the catalog + * @example [ + * "electronics", + * "apparel", + * "home_goods" + * ] + */ + categories?: string[]; + /** + * Format: date-time + * @description When the product catalog was last updated + */ + last_updated?: string; + /** + * @description How frequently the product catalog is updated + * @enum {string} + */ + update_frequency?: 'realtime' | 'hourly' | 'daily' | 'weekly'; + }; + /** + * @description Industry or vertical + * @example retail + */ + industry?: string; + /** + * @description Primary target audience description + * @example Tech-savvy millennials aged 25-40 + */ + target_audience?: string; + /** @description Legal disclaimers or required text that must appear in creatives */ + disclaimers?: { + /** @description Disclaimer text */ + text: string; + /** @description When this disclaimer applies (e.g., financial_products, all) */ + context?: string; + /** @description Whether this disclaimer must appear */ + required?: boolean; + }[]; + /** @description Brand contact information */ + contact?: { + /** + * Format: email + * @description Contact email + */ + email?: string; + /** @description Contact phone number */ + phone?: string; + }; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_proposed_tactics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['GetProposedTacticsRequest']; + }; + }; + responses: { + /** @description Tactic proposals from your agent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['GetProposedTacticsResponse']; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + manage_tactic: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ManageTacticRequest']; + }; + }; + responses: { + /** @description Acknowledgment of tactic assignment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ManageTacticResponse']; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + tactic_context_updated: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TacticContextUpdatedRequest']; + }; + }; + responses: { + /** @description Acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + tactic_creatives_updated: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TacticCreativesUpdatedRequest']; + }; + }; + responses: { + /** @description Acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + tactic_feedback: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TacticFeedbackRequest']; + }; + }; + responses: { + /** @description Acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} From 1d80dee911d4345e2e293f506e60b429cadc35f5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 13:35:26 -0400 Subject: [PATCH 02/11] Remove implementation plan - all stages complete --- IMPLEMENTATION_PLAN.md | 87 ------------------------------------------ 1 file changed, 87 deletions(-) delete mode 100644 IMPLEMENTATION_PLAN.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index cbc7c71..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,87 +0,0 @@ -# Media Agent MCP Implementation - -## Completed - -โœ… All stages completed successfully - -### Stage 1: Project Setup -**Goal**: Set up TypeScript types from OpenAPI spec -**Status**: Complete - -- Downloaded media-agent-openapi.yaml from PR #136 -- Generated TypeScript types using openapi-typescript -- Added npm script for regenerating types - -### Stage 2: MCP Server Implementation -**Goal**: Build MCP server that proxies to media agent -**Status**: Complete - -Created `src/media-agent-mcp.ts` with: -- MediaAgentMCP class implementing MCP Server -- All 5 tools from the Media Agent Protocol: - - get_proposed_tactics - - manage_tactic - - tactic_context_updated - - tactic_creatives_updated - - tactic_feedback -- HTTP client to call media agent endpoints -- Full TypeScript types from OpenAPI spec - -### Stage 3: Server Entry Point -**Goal**: Create runnable MCP server -**Status**: Complete - -Created `src/media-agent-server.ts`: -- CLI entry point with shebang -- Environment variable configuration -- Error handling -- Added bin entry to package.json - -### Stage 4: Documentation & Examples -**Goal**: Document usage and provide examples -**Status**: Complete - -Created: -- MEDIA_AGENT_MCP.md - comprehensive guide -- examples/media-agent-mcp.ts - programmatic usage -- examples/simple-media-agent.ts - reference implementation -- Tests in src/__tests__/media-agent-mcp.test.ts - -### Stage 5: Build & Verify -**Goal**: Ensure everything compiles and works -**Status**: Complete - -- Installed dependencies -- Built project successfully -- Tests passing -- Generated dist files ready to use - -## Usage - -### Run the MCP Server - -```bash -export MEDIA_AGENT_URL=https://your-media-agent.example.com -export MEDIA_AGENT_API_KEY=your_api_key -npx scope3-media-agent -``` - -### Use Programmatically - -```typescript -import { MediaAgentMCP } from '@scope3/agentic-client'; - -const server = new MediaAgentMCP({ - mediaAgentUrl: 'https://your-media-agent.example.com', - apiKey: process.env.MEDIA_AGENT_API_KEY, -}); - -await server.run(); -``` - -## Next Steps - -When the API is merged to main: -1. Update media-agent-openapi.yaml from official source -2. Regenerate types: `npm run generate-media-agent-types` -3. Test with real media agent implementation From 9cf7fbe89483ddffb434c7aa5fb112eaf9d07f7b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 13:58:44 -0400 Subject: [PATCH 03/11] Refactor to use fastmcp for simpler MCP server implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace low-level @modelcontextprotocol/sdk with fastmcp, a higher-level TypeScript framework that simplifies MCP server creation. Changes: - Replace Server/Transport setup with FastMCP class - Use Zod schemas for tool parameter validation - Simplify tool registration with addTool() - Update method from run() to start() - Add transformIgnorePatterns to Jest config - Mock fastmcp in tests to avoid ESM import issues Benefits: - Less boilerplate code (~170 lines vs ~300 lines) - Better type safety with Zod - Cleaner API surface ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MEDIA_AGENT_MCP.md | 4 +- examples/media-agent-mcp.ts | 2 +- jest.config.js | 1 + package-lock.json | 577 +++++++++++++++++++++++++- package.json | 4 +- src/__tests__/media-agent-mcp.test.ts | 8 + src/media-agent-mcp.ts | 436 ++++++------------- src/media-agent-server.ts | 2 +- 8 files changed, 718 insertions(+), 316 deletions(-) diff --git a/MEDIA_AGENT_MCP.md b/MEDIA_AGENT_MCP.md index 16e68be..a257548 100644 --- a/MEDIA_AGENT_MCP.md +++ b/MEDIA_AGENT_MCP.md @@ -1,6 +1,6 @@ # Media Agent MCP Server -MCP (Model Context Protocol) server implementation for Scope3 Media Agents. +MCP (Model Context Protocol) server implementation for Scope3 Media Agents, built with [fastmcp](https://www.npmjs.com/package/fastmcp). ## Overview @@ -36,7 +36,7 @@ const server = new MediaAgentMCP({ version: '1.0.0', }); -await server.run(); +await server.start(); ``` ## Available Tools diff --git a/examples/media-agent-mcp.ts b/examples/media-agent-mcp.ts index 5f254d0..dcc64cc 100644 --- a/examples/media-agent-mcp.ts +++ b/examples/media-agent-mcp.ts @@ -10,7 +10,7 @@ const server = new MediaAgentMCP({ version: '1.0.0', }); -server.run().catch((error) => { +server.start().catch((error) => { console.error('Fatal error:', error); process.exit(1); }); diff --git a/jest.config.js b/jest.config.js index 51956da..4e49cae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,5 @@ module.exports = { roots: ['/src'], testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts'], + transformIgnorePatterns: ['node_modules/(?!(fastmcp)/)'], }; diff --git a/package-lock.json b/package-lock.json index bf46816..ee0f376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", - "express": "^4.18.0" + "express": "^4.18.0", + "fastmcp": "^3.20.2", + "zod": "^3.25.76" }, "bin": { "scope3-media-agent": "dist/media-agent-server.js" @@ -559,6 +561,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", + "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.13.tgz", @@ -2045,6 +2057,12 @@ "node": ">= 8" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2052,6 +2070,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -2072,6 +2102,36 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3622,7 +3682,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4055,6 +4114,273 @@ "dev": true, "license": "MIT" }, + "node_modules/fastmcp": { + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/fastmcp/-/fastmcp-3.20.2.tgz", + "integrity": "sha512-5oQGAMZbxARnChEsfEq8l0O7XrBLCDQaZV0f9QGLXaPn+xjl9xPGJRzMhmuVB++2WTEoC5Ue6AtrvZYDPFSJiw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.2", + "@standard-schema/spec": "^1.0.0", + "execa": "^9.6.0", + "file-type": "^21.0.0", + "fuse.js": "^7.1.0", + "mcp-proxy": "^5.8.1", + "strict-event-emitter-types": "^2.0.0", + "undici": "^7.13.0", + "uri-templates": "^0.2.0", + "xsschema": "0.4.0-beta.5", + "yargs": "^18.0.0", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "fastmcp": "dist/bin/fastmcp.js" + } + }, + "node_modules/fastmcp/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/fastmcp/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/fastmcp/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fastmcp/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/fastmcp/node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fastmcp/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/fastmcp/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fastmcp/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/fastmcp/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fastmcp/node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/fastmcp/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/fastmcp/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/fastmcp/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4075,6 +4401,27 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4088,6 +4435,24 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz", + "integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.7", + "strtok3": "^10.2.2", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4237,6 +4602,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4251,7 +4625,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4261,7 +4634,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4579,6 +4951,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4749,6 +5141,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4781,6 +5185,18 @@ "node": ">=4" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -6129,6 +6545,15 @@ "node": ">= 0.4" } }, + "node_modules/mcp-proxy": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/mcp-proxy/-/mcp-proxy-5.9.0.tgz", + "integrity": "sha512-xonJSkuy4wmwXeykBFl0mjRMt4D0pAGCtFIfBFUz4O80VrO92ruJseIdASJO3wN6MdYHi3vbZLOQol3NsUpg4g==", + "license": "MIT", + "bin": { + "mcp-proxy": "dist/bin/mcp-proxy.js" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6587,6 +7012,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6833,6 +7270,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7577,6 +8029,12 @@ "node": ">= 0.8" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -7662,6 +8120,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7776,6 +8250,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", + "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.1.0", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7939,6 +8431,18 @@ "node": ">=0.8.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", @@ -7959,6 +8463,18 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -8018,6 +8534,12 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-templates": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uri-templates/-/uri-templates-0.2.0.tgz", + "integrity": "sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==", + "license": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8149,11 +8671,44 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xsschema": { + "version": "0.4.0-beta.5", + "resolved": "https://registry.npmjs.org/xsschema/-/xsschema-0.4.0-beta.5.tgz", + "integrity": "sha512-73pYwf1hMy++7SnOkghJdgdPaGi+Y5I0SaO6rIlxb1ouV6tEyDbEcXP82kyr32KQVTlUbFj6qewi9eUVEiXm+g==", + "license": "MIT", + "peerDependencies": { + "@valibot/to-json-schema": "^1.0.0", + "arktype": "^2.1.20", + "effect": "^3.16.0", + "sury": "^10.0.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -8221,6 +8776,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 62ea9d0..fa76b14 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", - "express": "^4.18.0" + "express": "^4.18.0", + "fastmcp": "^3.20.2", + "zod": "^3.25.76" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", diff --git a/src/__tests__/media-agent-mcp.test.ts b/src/__tests__/media-agent-mcp.test.ts index d76df4c..3dae9bb 100644 --- a/src/__tests__/media-agent-mcp.test.ts +++ b/src/__tests__/media-agent-mcp.test.ts @@ -1,3 +1,11 @@ +// Mock fastmcp to avoid ESM import issues in Jest +jest.mock('fastmcp', () => ({ + FastMCP: jest.fn().mockImplementation(() => ({ + addTool: jest.fn(), + start: jest.fn().mockResolvedValue(undefined), + })), +})); + import { MediaAgentMCP } from '../media-agent-mcp'; describe('MediaAgentMCP', () => { diff --git a/src/media-agent-mcp.ts b/src/media-agent-mcp.ts index ce64acb..34479be 100644 --- a/src/media-agent-mcp.ts +++ b/src/media-agent-mcp.ts @@ -1,19 +1,5 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - Tool, -} from '@modelcontextprotocol/sdk/types.js'; -import type { components } from './types/media-agent-api.js'; - -type GetProposedTacticsRequest = components['schemas']['GetProposedTacticsRequest']; -type GetProposedTacticsResponse = components['schemas']['GetProposedTacticsResponse']; -type ManageTacticRequest = components['schemas']['ManageTacticRequest']; -type ManageTacticResponse = components['schemas']['ManageTacticResponse']; -type TacticContextUpdatedRequest = components['schemas']['TacticContextUpdatedRequest']; -type TacticCreativesUpdatedRequest = components['schemas']['TacticCreativesUpdatedRequest']; -type TacticFeedbackRequest = components['schemas']['TacticFeedbackRequest']; +import { FastMCP } from 'fastmcp'; +import { z } from 'zod'; export interface MediaAgentMCPConfig { name?: string; @@ -23,237 +9,17 @@ export interface MediaAgentMCPConfig { } export class MediaAgentMCP { - private server: Server; + private server: FastMCP; private config: MediaAgentMCPConfig; constructor(config: MediaAgentMCPConfig) { this.config = config; - this.server = new Server( - { - name: config.name || 'media-agent-mcp', - version: config.version || '1.0.0', - }, - { - capabilities: { - tools: {}, - }, - } - ); - - this.setupHandlers(); - } - - private setupHandlers(): void { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: this.getTools(), - })); - - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - switch (name) { - case 'get_proposed_tactics': - return this.handleGetProposedTactics(args as GetProposedTacticsRequest); - case 'manage_tactic': - return this.handleManageTactic(args as ManageTacticRequest); - case 'tactic_context_updated': - return this.handleTacticContextUpdated(args as TacticContextUpdatedRequest); - case 'tactic_creatives_updated': - return this.handleTacticCreativesUpdated(args as TacticCreativesUpdatedRequest); - case 'tactic_feedback': - return this.handleTacticFeedback(args as TacticFeedbackRequest); - default: - throw new Error(`Unknown tool: ${name}`); - } + this.server = new FastMCP({ + name: config.name || 'media-agent-mcp', + version: (config.version as `${number}.${number}.${number}`) || '1.0.0', }); - } - private getTools(): Tool[] { - return [ - { - name: 'get_proposed_tactics', - description: - 'Get tactic proposals from the media agent. Called when setting up a campaign to ask what tactics the agent can handle.', - inputSchema: { - type: 'object', - properties: { - campaignId: { - type: 'string', - description: 'Campaign ID', - }, - budgetRange: { - type: 'object', - properties: { - min: { type: 'number' }, - max: { type: 'number' }, - currency: { type: 'string' }, - }, - }, - startDate: { - type: 'string', - description: 'Campaign start date (ISO 8601)', - }, - endDate: { - type: 'string', - description: 'Campaign end date (ISO 8601)', - }, - channels: { - type: 'array', - items: { - type: 'string', - enum: ['display', 'video', 'audio', 'native', 'ctv'], - }, - }, - countries: { - type: 'array', - items: { type: 'string' }, - description: 'ISO 3166-1 alpha-2 country codes', - }, - objectives: { - type: 'array', - items: { type: 'string' }, - }, - brief: { - type: 'string', - }, - acceptedPricingMethods: { - type: 'array', - items: { - type: 'string', - enum: ['cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'flat_rate'], - }, - }, - seatId: { - type: 'string', - description: 'Seat/account ID', - }, - }, - required: ['campaignId', 'seatId'], - }, - }, - { - name: 'manage_tactic', - description: - 'Accept or decline tactic assignment. Called when the agent is assigned to manage a tactic.', - inputSchema: { - type: 'object', - properties: { - tacticId: { - type: 'string', - description: 'ID of the tactic', - }, - tacticContext: { - type: 'object', - description: 'Complete tactic details', - }, - brandAgentId: { - type: 'string', - description: 'Brand agent ID', - }, - seatId: { - type: 'string', - description: 'Seat/account ID', - }, - customFields: { - type: 'object', - description: 'Custom fields from advertiser', - }, - }, - required: ['tacticId', 'tacticContext', 'brandAgentId', 'seatId'], - }, - }, - { - name: 'tactic_context_updated', - description: - 'Notification of tactic changes. MUST be handled as changes may impact targeting or budget.', - inputSchema: { - type: 'object', - properties: { - tacticId: { - type: 'string', - description: 'Tactic ID', - }, - tactic: { - type: 'object', - description: 'Current tactic state', - }, - patch: { - type: 'array', - items: { - type: 'object', - properties: { - op: { type: 'string', enum: ['add', 'remove', 'replace'] }, - path: { type: 'string' }, - value: {}, - }, - }, - }, - }, - required: ['tacticId', 'tactic', 'patch'], - }, - }, - { - name: 'tactic_creatives_updated', - description: - 'Notification of creative changes. Update media buys to use new creative assets.', - inputSchema: { - type: 'object', - properties: { - tacticId: { - type: 'string', - description: 'Tactic ID', - }, - creatives: { - type: 'array', - description: 'Updated creative assets', - }, - patch: { - type: 'array', - items: { - type: 'object', - properties: { - op: { type: 'string', enum: ['add', 'remove', 'replace'] }, - path: { type: 'string' }, - value: {}, - }, - }, - }, - }, - required: ['tacticId', 'creatives', 'patch'], - }, - }, - { - name: 'tactic_feedback', - description: - 'Performance feedback from orchestrator. MAY trigger updates to improve performance.', - inputSchema: { - type: 'object', - properties: { - tacticId: { - type: 'string', - description: 'Tactic ID', - }, - startDate: { - type: 'string', - description: 'Start of feedback interval (ISO 8601)', - }, - endDate: { - type: 'string', - description: 'End of feedback interval (ISO 8601)', - }, - deliveryIndex: { - type: 'number', - description: 'Delivery performance (100 = on target)', - }, - performanceIndex: { - type: 'number', - description: 'Performance vs target (100 = maximum)', - }, - }, - required: ['tacticId', 'startDate', 'endDate', 'deliveryIndex', 'performanceIndex'], - }, - }, - ]; + this.setupTools(); } private async callMediaAgent(endpoint: string, body: unknown): Promise { @@ -279,76 +45,134 @@ export class MediaAgentMCP { return response.json(); } - private async handleGetProposedTactics(args: GetProposedTacticsRequest) { - const response = (await this.callMediaAgent( - '/get-proposed-tactics', - args - )) as GetProposedTacticsResponse; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, - ], - }; - } - - private async handleManageTactic(args: ManageTacticRequest) { - const response = (await this.callMediaAgent('/manage-tactic', args)) as ManageTacticResponse; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, - ], - }; - } - - private async handleTacticContextUpdated(args: TacticContextUpdatedRequest) { - await this.callMediaAgent('/tactic-context-updated', args); - - return { - content: [ - { - type: 'text', - text: 'Tactic context update sent successfully', - }, - ], - }; - } + private setupTools(): void { + // get_proposed_tactics tool + this.server.addTool({ + name: 'get_proposed_tactics', + description: + 'Get tactic proposals from the media agent. Called when setting up a campaign to ask what tactics the agent can handle.', + parameters: z.object({ + campaignId: z.string().describe('Campaign ID'), + budgetRange: z + .object({ + min: z.number(), + max: z.number(), + currency: z.string(), + }) + .optional(), + startDate: z.string().optional().describe('Campaign start date (ISO 8601)'), + endDate: z.string().optional().describe('Campaign end date (ISO 8601)'), + channels: z + .array(z.enum(['display', 'video', 'audio', 'native', 'ctv'])) + .optional() + .describe('Media channels'), + countries: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2 country codes'), + objectives: z.array(z.string()).optional().describe('Campaign objectives'), + brief: z.string().optional().describe('Campaign brief text'), + acceptedPricingMethods: z + .array(z.enum(['cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'flat_rate'])) + .optional() + .describe('Accepted pricing methods'), + seatId: z.string().describe('Seat/account ID'), + }), + execute: async (args) => { + const response = await this.callMediaAgent('/get-proposed-tactics', args); + return JSON.stringify(response, null, 2); + }, + }); - private async handleTacticCreativesUpdated(args: TacticCreativesUpdatedRequest) { - await this.callMediaAgent('/tactic-creatives-updated', args); + // manage_tactic tool + this.server.addTool({ + name: 'manage_tactic', + description: + 'Accept or decline tactic assignment. Called when the agent is assigned to manage a tactic.', + parameters: z.object({ + tacticId: z.string().describe('ID of the tactic'), + tacticContext: z.object({}).passthrough().describe('Complete tactic details'), + brandAgentId: z.string().describe('Brand agent ID'), + seatId: z.string().describe('Seat/account ID'), + customFields: z + .object({}) + .passthrough() + .optional() + .describe('Custom fields from advertiser'), + }), + execute: async (args) => { + const response = await this.callMediaAgent('/manage-tactic', args); + return JSON.stringify(response, null, 2); + }, + }); - return { - content: [ - { - type: 'text', - text: 'Tactic creatives update sent successfully', - }, - ], - }; - } + // tactic_context_updated tool + this.server.addTool({ + name: 'tactic_context_updated', + description: + 'Notification of tactic changes. MUST be handled as changes may impact targeting or budget.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + tactic: z.object({}).passthrough().describe('Current tactic state'), + patch: z + .array( + z.object({ + op: z.enum(['add', 'remove', 'replace']), + path: z.string(), + value: z.unknown().optional(), + }) + ) + .describe('JSON Patch format changes'), + }), + execute: async (args) => { + await this.callMediaAgent('/tactic-context-updated', args); + return 'Tactic context update sent successfully'; + }, + }); - private async handleTacticFeedback(args: TacticFeedbackRequest) { - await this.callMediaAgent('/tactic-feedback', args); + // tactic_creatives_updated tool + this.server.addTool({ + name: 'tactic_creatives_updated', + description: + 'Notification of creative changes. Update media buys to use new creative assets.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + creatives: z.array(z.unknown()).describe('Updated creative assets'), + patch: z + .array( + z.object({ + op: z.enum(['add', 'remove', 'replace']), + path: z.string(), + value: z.unknown().optional(), + }) + ) + .describe('JSON Patch format changes'), + }), + execute: async (args) => { + await this.callMediaAgent('/tactic-creatives-updated', args); + return 'Tactic creatives update sent successfully'; + }, + }); - return { - content: [ - { - type: 'text', - text: 'Tactic feedback sent successfully', - }, - ], - }; + // tactic_feedback tool + this.server.addTool({ + name: 'tactic_feedback', + description: + 'Performance feedback from orchestrator. MAY trigger updates to improve performance.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + startDate: z.string().describe('Start of feedback interval (ISO 8601)'), + endDate: z.string().describe('End of feedback interval (ISO 8601)'), + deliveryIndex: z.number().describe('Delivery performance (100 = on target)'), + performanceIndex: z.number().describe('Performance vs target (100 = maximum)'), + }), + execute: async (args) => { + await this.callMediaAgent('/tactic-feedback', args); + return 'Tactic feedback sent successfully'; + }, + }); } - async run(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); + async start(): Promise { + await this.server.start({ + transportType: 'stdio', + }); } } diff --git a/src/media-agent-server.ts b/src/media-agent-server.ts index b68c4c4..d0d17ff 100644 --- a/src/media-agent-server.ts +++ b/src/media-agent-server.ts @@ -16,7 +16,7 @@ const server = new MediaAgentMCP({ version: '1.0.0', }); -server.run().catch((error) => { +server.start().catch((error) => { console.error('Fatal error:', error); process.exit(1); }); From dd3cadad6dc05ea7192e7a11e3ab0348dd256828 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 15:58:00 -0400 Subject: [PATCH 04/11] Add simple media agent implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a basic passthrough media agent that: - Fetches products from all registered sales agents - Proposes tactics based on floor prices - Allocates budget to N cheapest products - Creates media buys when tactic is assigned - Listens for daily reporting to trigger reallocation Algorithm: - Sorts products by floor price - Calculates N where daily budget >= min daily budget ($100) - Divides total budget equally among N products Features: - CLI entry point: npx simple-media-agent - Programmatic API - Implements full Media Agent Protocol - Webhook for daily reporting complete - Extensible for custom strategies This is a reference implementation for building custom media agents. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- SIMPLE_MEDIA_AGENT.md | 191 ++++++++++++++++++++ package.json | 3 +- src/index.ts | 1 + src/simple-media-agent-server.ts | 30 +++ src/simple-media-agent.ts | 301 +++++++++++++++++++++++++++++++ 5 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 SIMPLE_MEDIA_AGENT.md create mode 100644 src/simple-media-agent-server.ts create mode 100644 src/simple-media-agent.ts diff --git a/SIMPLE_MEDIA_AGENT.md b/SIMPLE_MEDIA_AGENT.md new file mode 100644 index 0000000..bbcc89c --- /dev/null +++ b/SIMPLE_MEDIA_AGENT.md @@ -0,0 +1,191 @@ +# Simple Media Agent + +A basic reference implementation of a media agent that implements the Media Agent Protocol. + +## Overview + +This simple media agent demonstrates a passthrough strategy: +1. **Get Proposed Tactics**: Fetches products from all registered sales agents and proposes budget allocation based on floor prices +2. **Manage Tactic**: When assigned, creates media buys by allocating budget to the N cheapest products +3. **Reallocation**: Responds to daily reporting signals to reallocate budget based on performance + +## Algorithm + +### Budget Allocation +- Fetches all products from registered sales agents +- Sorts products by floor price (cheapest first) +- Calculates N = number of products where daily budget โ‰ฅ min daily budget (default $100) +- Selects N cheapest products +- Divides total budget equally among N products + +### Reallocation +- Listens for daily `reporting-complete` webhook +- Analyzes performance data from all media buys +- Reallocates budget based on performance (TODO: implement reallocation logic) + +## Installation + +```bash +npm install @scope3/agentic-client +``` + +## Usage + +### As a Standalone Server + +```bash +export SCOPE3_API_KEY=your_api_key +export PORT=8080 +export MIN_DAILY_BUDGET=100 +npx simple-media-agent +``` + +### Programmatically + +```typescript +import { SimpleMediaAgent } from '@scope3/agentic-client'; + +const agent = new SimpleMediaAgent({ + scope3ApiKey: process.env.SCOPE3_API_KEY, + scope3BaseUrl: 'https://api.agentic.scope3.com', + port: 8080, + minDailyBudget: 100, +}); + +agent.start(); +``` + +## Configuration + +### Environment Variables + +- `SCOPE3_API_KEY` (required): Your Scope3 API key +- `SCOPE3_BASE_URL` (optional): Scope3 API base URL (default: https://api.agentic.scope3.com) +- `PORT` (optional): Port to listen on (default: 8080) +- `MIN_DAILY_BUDGET` (optional): Minimum daily budget per product in USD (default: 100) + +## Endpoints + +The agent implements all required Media Agent Protocol endpoints: + +### POST /get-proposed-tactics +Returns tactic proposals based on available products and floor prices. + +**Request:** +```json +{ + "campaignId": "campaign-123", + "budgetRange": { "min": 10000, "max": 50000, "currency": "USD" }, + "channels": ["display", "video"], + "countries": ["US", "CA"], + "brief": "Campaign brief text", + "seatId": "seat-123" +} +``` + +**Response:** +```json +{ + "proposedTactics": [ + { + "tacticId": "simple-passthrough-campaign-123", + "execution": "Passthrough strategy: distribute budget across N products", + "budgetCapacity": 50000, + "pricing": { + "method": "passthrough", + "estimatedCpm": 2.50, + "currency": "USD" + }, + "sku": "simple-passthrough" + } + ] +} +``` + +### POST /manage-tactic +Accepts tactic assignment and creates media buys. + +### POST /tactic-context-updated +Handles tactic context changes (budget, schedule, etc.). + +### POST /tactic-creatives-updated +Handles creative updates. + +### POST /tactic-feedback +Receives performance feedback from orchestrator. + +### POST /webhook/reporting-complete +Handles daily reporting complete signal and triggers reallocation. + +**Request:** +```json +{ + "tacticId": "tactic-123", + "reportingData": { + "date": "2025-10-20", + "impressions": 100000, + "spend": 250.00 + } +} +``` + +## Connecting to the MCP Server + +To use this agent with the MCP server: + +1. Start the simple media agent: + ```bash + export SCOPE3_API_KEY=your_api_key + npx simple-media-agent + ``` + +2. Start the MCP server pointing to the media agent: + ```bash + export MEDIA_AGENT_URL=http://localhost:8080 + npx scope3-media-agent + ``` + +3. The MCP server will now proxy calls to your simple media agent + +## Extending the Agent + +This is a reference implementation. To build your own media agent: + +1. **Custom Product Selection**: Modify `handleGetProposedTactics` to filter/rank products differently +2. **Budget Allocation**: Update `calculateBudgetAllocation` with your own algorithm +3. **Reallocation Logic**: Implement performance-based reallocation in `handleReportingComplete` +4. **Optimization**: Add ML models, historical data analysis, or custom signals + +## Example Custom Extensions + +### Use Brand Story for Targeting +```typescript +const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + brandStoryId: tacticContext.brandStoryId, +}); +``` + +### Optimize for vCPM +```typescript +// Filter products by viewability +const highViewabilityProducts = allProducts.filter(p => + p.viewability >= 0.85 +); +``` + +### Performance-Based Reallocation +```typescript +// In handleReportingComplete +const sortedByPerformance = mediaBuys.sort((a, b) => + b.performanceIndex - a.performanceIndex +); + +// Increase budget for top performers +for (const mediaBuy of sortedByPerformance.slice(0, 3)) { + await this.scope3.mediaBuys.update({ + mediaBuyId: mediaBuy.id, + budget: { amount: mediaBuy.budget * 1.2 }, + }); +} +``` diff --git a/package.json b/package.json index fa76b14..e84b753 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "scope3-media-agent": "dist/media-agent-server.js" + "scope3-media-agent": "dist/media-agent-server.js", + "simple-media-agent": "dist/simple-media-agent-server.js" }, "scripts": { "build": "npm run type-check && tsc", diff --git a/src/index.ts b/src/index.ts index e0992e6..c1aaa47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { Scope3AgenticClient } from './sdk'; export { Scope3AgenticClient as Scope3SDK } from './sdk'; export { WebhookServer } from './webhook-server'; export { MediaAgentMCP } from './media-agent-mcp'; +export { SimpleMediaAgent } from './simple-media-agent'; export type { ClientConfig, ToolResponse, Environment } from './types'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; export type { MediaAgentMCPConfig } from './media-agent-mcp'; diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts new file mode 100644 index 0000000..7cdad13 --- /dev/null +++ b/src/simple-media-agent-server.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { SimpleMediaAgent } from './simple-media-agent.js'; + +const scope3ApiKey = process.env.SCOPE3_API_KEY; +const scope3BaseUrl = process.env.SCOPE3_BASE_URL; +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; +const minDailyBudget = process.env.MIN_DAILY_BUDGET + ? parseFloat(process.env.MIN_DAILY_BUDGET) + : 100; + +if (!scope3ApiKey) { + console.error('Error: SCOPE3_API_KEY environment variable is required'); + process.exit(1); +} + +const agent = new SimpleMediaAgent({ + scope3ApiKey, + scope3BaseUrl, + port, + minDailyBudget, +}); + +agent.start(); + +console.log(` +Simple Media Agent Configuration: +- Port: ${port} +- Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} +- Min Daily Budget: $${minDailyBudget} +`); diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts new file mode 100644 index 0000000..4072080 --- /dev/null +++ b/src/simple-media-agent.ts @@ -0,0 +1,301 @@ +import express from 'express'; +import type { Request, Response } from 'express'; +import { Scope3AgenticClient } from './sdk'; + +interface SimpleMediaAgentConfig { + port?: number; + scope3ApiKey: string; + scope3BaseUrl?: string; + minDailyBudget?: number; +} + +interface Product { + id: string; + salesAgentId: string; + floorPrice?: number; + recommendedPrice?: number; + name?: string; +} + +interface MediaBuyAllocation { + productId: string; + salesAgentId: string; + budget: number; + cpm: number; +} + +export class SimpleMediaAgent { + private app: express.Application; + private config: Required; + private scope3: Scope3AgenticClient; + private activeTactics: Map; + + constructor(config: SimpleMediaAgentConfig) { + this.config = { + port: config.port || 8080, + scope3ApiKey: config.scope3ApiKey, + scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', + minDailyBudget: config.minDailyBudget || 100, + }; + + this.scope3 = new Scope3AgenticClient({ + apiKey: this.config.scope3ApiKey, + baseUrl: this.config.scope3BaseUrl, + }); + + this.activeTactics = new Map(); + this.app = express(); + this.app.use(express.json()); + this.setupRoutes(); + } + + private setupRoutes(): void { + this.app.post('/get-proposed-tactics', this.handleGetProposedTactics.bind(this)); + this.app.post('/manage-tactic', this.handleManageTactic.bind(this)); + this.app.post('/tactic-context-updated', this.handleTacticContextUpdated.bind(this)); + this.app.post('/tactic-creatives-updated', this.handleTacticCreativesUpdated.bind(this)); + this.app.post('/tactic-feedback', this.handleTacticFeedback.bind(this)); + this.app.post('/webhook/reporting-complete', this.handleReportingComplete.bind(this)); + } + + private async handleGetProposedTactics(req: Request, res: Response): Promise { + try { + const { campaignId, budgetRange } = req.body; + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Call getProducts for each sales agent with the brief + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate average floor price for proposal + const avgFloorPrice = + allProducts.length > 0 + ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length + : 0; + + const proposedTactics = [ + { + tacticId: `simple-passthrough-${campaignId}`, + execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices`, + budgetCapacity: budgetRange?.max || 0, + pricing: { + method: 'passthrough', + estimatedCpm: avgFloorPrice, + currency: 'USD', + }, + sku: 'simple-passthrough', + metadata: { + productCount: allProducts.length, + avgFloorPrice, + }, + }, + ]; + + res.json({ proposedTactics }); + } catch (error) { + console.error('Error in get-proposed-tactics:', error); + res.status(500).json({ error: 'Failed to generate tactic proposals' }); + } + } + + private async handleManageTactic(req: Request, res: Response): Promise { + try { + const { tacticId, tacticContext } = req.body; + + console.log(`Managing tactic ${tacticId}`); + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Get products from all sales agents + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate budget allocation + const totalBudget = tacticContext.budget?.amount || 0; + const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); + + // Store tactic info for later reallocation + this.activeTactics.set(tacticId, { + products: allProducts, + allocations, + }); + + // Create media buys for each allocation + for (const allocation of allocations) { + try { + await this.scope3.mediaBuys.create({ + tacticId, + name: `Media Buy - ${allocation.productId}`, + products: [ + { + mediaProductId: allocation.productId, + salesAgentId: allocation.salesAgentId, + pricingCpm: allocation.cpm, + }, + ], + budget: { + amount: allocation.budget, + currency: 'USD', + }, + }); + } catch (error) { + console.error(`Error creating media buy for product ${allocation.productId}:`, error); + } + } + + res.json({ + acknowledged: true, + mediaBuysCreated: allocations.length, + }); + } catch (error) { + console.error('Error in manage-tactic:', error); + res.status(500).json({ error: 'Failed to manage tactic' }); + } + } + + private calculateBudgetAllocation( + products: Product[], + totalBudget: number + ): MediaBuyAllocation[] { + if (products.length === 0) return []; + + // Calculate N = number of products where daily budget >= minDailyBudget + // Assume campaign runs for 30 days for this calculation + const assumedDays = 30; + const maxProducts = Math.floor(totalBudget / assumedDays / this.config.minDailyBudget); + const n = Math.min(maxProducts, products.length); + + if (n === 0) return []; + + // Take N cheapest products + const selectedProducts = products.slice(0, n); + + // Divide budget equally + const budgetPerProduct = totalBudget / n; + + return selectedProducts.map((product) => ({ + productId: product.id, + salesAgentId: product.salesAgentId, + budget: budgetPerProduct, + cpm: product.floorPrice || 0, + })); + } + + private async handleTacticContextUpdated(req: Request, res: Response): Promise { + const { tacticId, patch } = req.body; + console.log(`Tactic ${tacticId} context updated:`, patch); + + // Check if budget changed + const budgetChange = patch.find((p: { path: string }) => p.path.startsWith('/budget')); + if (budgetChange && this.activeTactics.has(tacticId)) { + console.log(`Budget changed for tactic ${tacticId}, will reallocate on next reporting cycle`); + } + + res.json({ acknowledged: true }); + } + + private async handleTacticCreativesUpdated(req: Request, res: Response): Promise { + const { tacticId, patch } = req.body; + console.log(`Creatives for tactic ${tacticId} updated:`, patch); + + // Update media buys with new creatives if needed + res.json({ acknowledged: true }); + } + + private async handleTacticFeedback(req: Request, res: Response): Promise { + const { tacticId, deliveryIndex, performanceIndex } = req.body; + console.log(`Feedback for tactic ${tacticId}:`, { deliveryIndex, performanceIndex }); + + res.json({ acknowledged: true }); + } + + private async handleReportingComplete(req: Request, res: Response): Promise { + try { + const { tacticId, reportingData } = req.body; + console.log(`Daily reporting complete for tactic ${tacticId}`); + + if (!this.activeTactics.has(tacticId)) { + res.json({ acknowledged: true, message: 'Tactic not found' }); + return; + } + + // Get current media buys and their performance + const mediaBuysResponse = await this.scope3.mediaBuys.list({ tacticId }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mediaBuys = (mediaBuysResponse.data as any[]) || []; + + console.log(`Reallocating budget for ${mediaBuys.length} media buys`); + + // Analyze performance and reallocate + // For now, just log - in production, you'd implement reallocation logic here + for (const mediaBuy of mediaBuys) { + console.log(`Media buy ${mediaBuy.id}: performance data`, reportingData); + } + + res.json({ + acknowledged: true, + message: 'Reallocation triggered', + }); + } catch (error) { + console.error('Error in reporting-complete:', error); + res.status(500).json({ error: 'Failed to process reporting data' }); + } + } + + start(): void { + this.app.listen(this.config.port, () => { + console.log(`Simple media agent listening on port ${this.config.port}`); + }); + } +} From 6695850dd39aa42818865834aa38c0cb5f31ced3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 16:06:56 -0400 Subject: [PATCH 05/11] Add overallocation percentage to ensure delivery targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply configurable overallocation (default 40%) to media buy budgets to ensure campaign delivery targets are met even with underdelivery. Changes: - Add overallocationPercent config option (default: 40%) - Apply multiplier in calculateBudgetAllocation() - Add OVERALLOCATION_PERCENT env var - Update documentation with example Example: For $10,000 campaign with 40% overallocation, allocate $14,000 across media buys to hit $10,000 spend target. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- SIMPLE_MEDIA_AGENT.md | 8 +++++++- src/simple-media-agent-server.ts | 5 +++++ src/simple-media-agent.ts | 13 ++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/SIMPLE_MEDIA_AGENT.md b/SIMPLE_MEDIA_AGENT.md index bbcc89c..609336b 100644 --- a/SIMPLE_MEDIA_AGENT.md +++ b/SIMPLE_MEDIA_AGENT.md @@ -14,9 +14,12 @@ This simple media agent demonstrates a passthrough strategy: ### Budget Allocation - Fetches all products from registered sales agents - Sorts products by floor price (cheapest first) +- **Applies overallocation** (default 40%) to ensure delivery targets are met - Calculates N = number of products where daily budget โ‰ฅ min daily budget (default $100) - Selects N cheapest products -- Divides total budget equally among N products +- Divides overallocated budget equally among N products + +**Example**: For a $10,000 campaign with 40% overallocation, the agent allocates $14,000 across media buys. This ensures you hit your $10,000 spend target even with underdelivery. ### Reallocation - Listens for daily `reporting-complete` webhook @@ -37,6 +40,7 @@ npm install @scope3/agentic-client export SCOPE3_API_KEY=your_api_key export PORT=8080 export MIN_DAILY_BUDGET=100 +export OVERALLOCATION_PERCENT=40 npx simple-media-agent ``` @@ -50,6 +54,7 @@ const agent = new SimpleMediaAgent({ scope3BaseUrl: 'https://api.agentic.scope3.com', port: 8080, minDailyBudget: 100, + overallocationPercent: 40, }); agent.start(); @@ -63,6 +68,7 @@ agent.start(); - `SCOPE3_BASE_URL` (optional): Scope3 API base URL (default: https://api.agentic.scope3.com) - `PORT` (optional): Port to listen on (default: 8080) - `MIN_DAILY_BUDGET` (optional): Minimum daily budget per product in USD (default: 100) +- `OVERALLOCATION_PERCENT` (optional): Overallocation percentage to ensure delivery (default: 40) ## Endpoints diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts index 7cdad13..4b878b4 100644 --- a/src/simple-media-agent-server.ts +++ b/src/simple-media-agent-server.ts @@ -7,6 +7,9 @@ const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; const minDailyBudget = process.env.MIN_DAILY_BUDGET ? parseFloat(process.env.MIN_DAILY_BUDGET) : 100; +const overallocationPercent = process.env.OVERALLOCATION_PERCENT + ? parseFloat(process.env.OVERALLOCATION_PERCENT) + : 40; if (!scope3ApiKey) { console.error('Error: SCOPE3_API_KEY environment variable is required'); @@ -18,6 +21,7 @@ const agent = new SimpleMediaAgent({ scope3BaseUrl, port, minDailyBudget, + overallocationPercent, }); agent.start(); @@ -27,4 +31,5 @@ Simple Media Agent Configuration: - Port: ${port} - Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} - Min Daily Budget: $${minDailyBudget} +- Overallocation: ${overallocationPercent}% `); diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts index 4072080..6bda935 100644 --- a/src/simple-media-agent.ts +++ b/src/simple-media-agent.ts @@ -7,6 +7,7 @@ interface SimpleMediaAgentConfig { scope3ApiKey: string; scope3BaseUrl?: string; minDailyBudget?: number; + overallocationPercent?: number; } interface Product { @@ -36,6 +37,7 @@ export class SimpleMediaAgent { scope3ApiKey: config.scope3ApiKey, scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', minDailyBudget: config.minDailyBudget || 100, + overallocationPercent: config.overallocationPercent || 40, }; this.scope3 = new Scope3AgenticClient({ @@ -210,10 +212,15 @@ export class SimpleMediaAgent { ): MediaBuyAllocation[] { if (products.length === 0) return []; + // Apply overallocation to ensure delivery + // If we want to spend $100/day, allocate $140/day to hit the target + const overallocationMultiplier = 1 + this.config.overallocationPercent / 100; + const allocatedBudget = totalBudget * overallocationMultiplier; + // Calculate N = number of products where daily budget >= minDailyBudget // Assume campaign runs for 30 days for this calculation const assumedDays = 30; - const maxProducts = Math.floor(totalBudget / assumedDays / this.config.minDailyBudget); + const maxProducts = Math.floor(allocatedBudget / assumedDays / this.config.minDailyBudget); const n = Math.min(maxProducts, products.length); if (n === 0) return []; @@ -221,8 +228,8 @@ export class SimpleMediaAgent { // Take N cheapest products const selectedProducts = products.slice(0, n); - // Divide budget equally - const budgetPerProduct = totalBudget / n; + // Divide budget equally with overallocation applied + const budgetPerProduct = allocatedBudget / n; return selectedProducts.map((product) => ({ productId: product.id, From 064b5bbbeed4adf913d4f83ede651be1006f69b8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 16:15:26 -0400 Subject: [PATCH 06/11] Add SimpleMediaAgentMCP - media agent as native MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You're right! The media agent SHOULD be the MCP server directly, not need a proxy layer. Two deployment models: 1. simple-media-agent (HTTP) - Called BY Scope3 platform 2. simple-media-agent-mcp (MCP) - Called BY brand agents via MCP The generic MCP proxy (scope3-media-agent) is only needed for third-party HTTP-only media agents. Architecture: - HTTP Model: Scope3 โ†’ HTTP POST โ†’ Express server - MCP Model: Brand Agent โ†’ MCP stdio โ†’ FastMCP server - Both: โ†’ Scope3 API Files: - src/simple-media-agent.ts - HTTP server (Express) - src/simple-media-agent-with-mcp.ts - MCP server (FastMCP) - src/simple-media-agent-mcp-server.ts - MCP CLI entry point - ARCHITECTURE.md - Explains both models clearly ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ARCHITECTURE.md | 135 ++++++++++++ package.json | 3 +- src/simple-media-agent-mcp-server.ts | 37 ++++ src/simple-media-agent-with-mcp.ts | 297 +++++++++++++++++++++++++++ 4 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md create mode 100644 src/simple-media-agent-mcp-server.ts create mode 100644 src/simple-media-agent-with-mcp.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ce214a4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,135 @@ +# Media Agent Architecture + +## Two Deployment Models + +There are **two ways** to deploy a media agent, depending on who's calling it: + +### 1. HTTP Media Agent (for Scope3 Orchestrator) + +**Use When**: The Scope3 platform orchestrator calls your media agent + +``` +Scope3 Orchestrator โ†’ HTTP POST โ†’ Media Agent (Express) โ†’ Scope3 API +``` + +**Command**: +```bash +export SCOPE3_API_KEY=your_key +npx simple-media-agent # Runs on port 8080 +``` + +**What It Does**: +- Exposes HTTP endpoints (`/get-proposed-tactics`, `/manage-tactic`, etc.) +- Waits for Scope3 platform to POST requests +- Implements Media Agent Protocol over HTTP + +**File**: `src/simple-media-agent.ts` + +--- + +### 2. MCP Media Agent (for Brand Agents) + +**Use When**: A brand agent (running in Claude/LLM) calls your media agent + +``` +Claude/Brand Agent โ†’ MCP stdio โ†’ Media Agent (MCP Server) โ†’ Scope3 API +``` + +**Command**: +```bash +export SCOPE3_API_KEY=your_key +npx simple-media-agent-mcp # MCP stdio +``` + +**What It Does**: +- Exposes MCP tools (`get_proposed_tactics`, `manage_tactic`) +- Communicates via stdio (MCP protocol) +- Brand agent can directly call tools + +**File**: `src/simple-media-agent-with-mcp.ts` + +--- + +## Why Two Models? + +### HTTP Model +- โœ… Called BY the Scope3 platform +- โœ… Receives webhooks for reporting, tactic updates +- โœ… Standard web server architecture +- โŒ Not directly callable by LLMs + +### MCP Model +- โœ… Called BY brand agents (LLMs) +- โœ… Native LLM integration +- โœ… No HTTP server needed +- โŒ Can't receive webhooks from Scope3 + +--- + +## The Generic MCP Proxy (Deprecated for Our Use Case) + +**What is `scope3-media-agent`?** + +This is a **generic MCP proxy** that forwards MCP calls to ANY media agent's HTTP endpoints: + +``` +Claude โ†’ MCP Proxy โ†’ HTTP POST โ†’ Any Media Agent +``` + +**Why it exists**: To let brand agents call media agents that only expose HTTP endpoints. + +**Why you don't need it**: If your media agent exposes MCP tools directly (`simple-media-agent-mcp`), the proxy is unnecessary! + +--- + +## Quick Decision Guide + +**Q: Who is calling my media agent?** + +- **Scope3 Platform** โ†’ Use `npx simple-media-agent` (HTTP) +- **Brand Agent/LLM** โ†’ Use `npx simple-media-agent-mcp` (MCP) +- **Third-party HTTP media agent** โ†’ Use `npx scope3-media-agent` (MCP Proxy) + +--- + +## Examples + +### Example 1: Platform Integration +```bash +# Deploy media agent that Scope3 platform can call +npx simple-media-agent +# โ†’ HTTP server on port 8080 +# โ†’ Scope3 platform POSTs to http://your-domain:8080/manage-tactic +``` + +### Example 2: Brand Agent Integration +```bash +# Brand agent running in Claude Desktop +export SCOPE3_API_KEY=your_key +npx simple-media-agent-mcp +# โ†’ MCP server on stdio +# โ†’ Brand agent calls get_proposed_tactics tool +# โ†’ No HTTP server needed! +``` + +### Example 3: Third-Party Media Agent +```bash +# Terminal 1: Some other media agent with HTTP endpoints +npx other-media-agent --port 8080 + +# Terminal 2: MCP proxy for brand agent to call it +export MEDIA_AGENT_URL=http://localhost:8080 +npx scope3-media-agent +# โ†’ Brand agent calls MCP tools +# โ†’ Proxy forwards to HTTP endpoints +``` + +--- + +## Summary + +- **`simple-media-agent`**: HTTP server for Scope3 platform +- **`simple-media-agent-mcp`**: MCP server for brand agents (NO PROXY NEEDED!) +- **`scope3-media-agent`**: Generic MCPโ†’HTTP proxy (for third-party agents) + +You were right to question the proxy! For our simple media agent, the MCP version (`simple-media-agent-mcp`) IS the MCP server directly. diff --git a/package.json b/package.json index e84b753..7f901f6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "types": "dist/index.d.ts", "bin": { "scope3-media-agent": "dist/media-agent-server.js", - "simple-media-agent": "dist/simple-media-agent-server.js" + "simple-media-agent": "dist/simple-media-agent-server.js", + "simple-media-agent-mcp": "dist/simple-media-agent-mcp-server.js" }, "scripts": { "build": "npm run type-check && tsc", diff --git a/src/simple-media-agent-mcp-server.ts b/src/simple-media-agent-mcp-server.ts new file mode 100644 index 0000000..3cbb933 --- /dev/null +++ b/src/simple-media-agent-mcp-server.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { SimpleMediaAgentMCP } from './simple-media-agent-with-mcp.js'; + +const scope3ApiKey = process.env.SCOPE3_API_KEY; +const scope3BaseUrl = process.env.SCOPE3_BASE_URL; +const minDailyBudget = process.env.MIN_DAILY_BUDGET + ? parseFloat(process.env.MIN_DAILY_BUDGET) + : 100; +const overallocationPercent = process.env.OVERALLOCATION_PERCENT + ? parseFloat(process.env.OVERALLOCATION_PERCENT) + : 40; + +if (!scope3ApiKey) { + console.error('Error: SCOPE3_API_KEY environment variable is required'); + process.exit(1); +} + +const agent = new SimpleMediaAgentMCP({ + scope3ApiKey, + scope3BaseUrl, + minDailyBudget, + overallocationPercent, + name: 'simple-media-agent', + version: '1.0.0', +}); + +agent.start().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); + +console.error(` +Simple Media Agent MCP Server +- Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} +- Min Daily Budget: $${minDailyBudget} +- Overallocation: ${overallocationPercent}% +`); diff --git a/src/simple-media-agent-with-mcp.ts b/src/simple-media-agent-with-mcp.ts new file mode 100644 index 0000000..a81dcd9 --- /dev/null +++ b/src/simple-media-agent-with-mcp.ts @@ -0,0 +1,297 @@ +import { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { Scope3AgenticClient } from './sdk'; + +interface SimpleMediaAgentMCPConfig { + scope3ApiKey: string; + scope3BaseUrl?: string; + minDailyBudget?: number; + overallocationPercent?: number; + name?: string; + version?: string; +} + +interface Product { + id: string; + salesAgentId: string; + floorPrice?: number; + recommendedPrice?: number; + name?: string; +} + +interface MediaBuyAllocation { + productId: string; + salesAgentId: string; + budget: number; + cpm: number; +} + +/** + * Simple Media Agent that exposes MCP tools directly + * This allows brand agents to interact with it via MCP protocol + */ +export class SimpleMediaAgentMCP { + private server: FastMCP; + private scope3: Scope3AgenticClient; + private config: Required< + Omit & { name: string; version: string } + >; + private activeTactics: Map; + + constructor(config: SimpleMediaAgentMCPConfig) { + this.config = { + scope3ApiKey: config.scope3ApiKey, + scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', + minDailyBudget: config.minDailyBudget || 100, + overallocationPercent: config.overallocationPercent || 40, + name: config.name || 'simple-media-agent', + version: (config.version as `${number}.${number}.${number}`) || '1.0.0', + }; + + this.server = new FastMCP({ + name: this.config.name, + version: this.config.version as `${number}.${number}.${number}`, + }); + + this.scope3 = new Scope3AgenticClient({ + apiKey: this.config.scope3ApiKey, + baseUrl: this.config.scope3BaseUrl, + }); + + this.activeTactics = new Map(); + this.setupTools(); + } + + private setupTools(): void { + // get_proposed_tactics tool + this.server.addTool({ + name: 'get_proposed_tactics', + description: + 'Get tactic proposals from this media agent. Returns budget allocation based on floor prices from sales agents.', + parameters: z.object({ + campaignId: z.string().describe('Campaign ID'), + budgetRange: z + .object({ + min: z.number(), + max: z.number(), + currency: z.string(), + }) + .optional(), + channels: z.array(z.string()).optional().describe('Media channels'), + countries: z.array(z.string()).optional().describe('ISO country codes'), + seatId: z.string().describe('Seat/account ID'), + }), + execute: async (args) => { + const result = await this.handleGetProposedTactics(args); + return JSON.stringify(result, null, 2); + }, + }); + + // manage_tactic tool + this.server.addTool({ + name: 'manage_tactic', + description: + 'Assign this media agent to manage a tactic. Creates media buys with overallocated budgets.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + tacticContext: z.object({}).passthrough().describe('Tactic details including budget'), + brandAgentId: z.string().describe('Brand agent ID'), + seatId: z.string().describe('Seat/account ID'), + }), + execute: async (args) => { + const result = await this.handleManageTactic(args); + return JSON.stringify(result, null, 2); + }, + }); + } + + private async handleGetProposedTactics(args: { + campaignId: string; + budgetRange?: { min: number; max: number; currency: string }; + seatId: string; + }): Promise { + const { campaignId, budgetRange } = args; + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Call getProducts for each sales agent + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate average floor price for proposal + const avgFloorPrice = + allProducts.length > 0 + ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length + : 0; + + return { + proposedTactics: [ + { + tacticId: `simple-passthrough-${campaignId}`, + execution: `Passthrough strategy with ${this.config.overallocationPercent}% overallocation: distribute budget across ${allProducts.length} products based on floor prices`, + budgetCapacity: budgetRange?.max || 0, + pricing: { + method: 'passthrough', + estimatedCpm: avgFloorPrice, + currency: 'USD', + }, + sku: 'simple-passthrough', + metadata: { + productCount: allProducts.length, + avgFloorPrice, + overallocationPercent: this.config.overallocationPercent, + }, + }, + ], + }; + } + + private async handleManageTactic(args: { + tacticId: string; + tacticContext: { budget?: { amount: number } }; + brandAgentId: string; + seatId: string; + }): Promise { + const { tacticId, tacticContext } = args; + + console.log(`Managing tactic ${tacticId}`); + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Get products from all sales agents + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate budget allocation + const totalBudget = tacticContext.budget?.amount || 0; + const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); + + // Store tactic info for later reallocation + this.activeTactics.set(tacticId, { + products: allProducts, + allocations, + }); + + // Create media buys for each allocation + const createdBuys = []; + for (const allocation of allocations) { + try { + const result = await this.scope3.mediaBuys.create({ + tacticId, + name: `Media Buy - ${allocation.productId}`, + products: [ + { + mediaProductId: allocation.productId, + salesAgentId: allocation.salesAgentId, + pricingCpm: allocation.cpm, + }, + ], + budget: { + amount: allocation.budget, + currency: 'USD', + }, + }); + createdBuys.push(result.data); + } catch (error) { + console.error(`Error creating media buy for product ${allocation.productId}:`, error); + } + } + + return { + acknowledged: true, + mediaBuysCreated: createdBuys.length, + allocations: allocations.map((a) => ({ + productId: a.productId, + budget: a.budget, + cpm: a.cpm, + })), + }; + } + + private calculateBudgetAllocation( + products: Product[], + totalBudget: number + ): MediaBuyAllocation[] { + if (products.length === 0) return []; + + // Apply overallocation to ensure delivery + const overallocationMultiplier = 1 + this.config.overallocationPercent / 100; + const allocatedBudget = totalBudget * overallocationMultiplier; + + // Calculate N = number of products where daily budget >= minDailyBudget + const assumedDays = 30; + const maxProducts = Math.floor(allocatedBudget / assumedDays / this.config.minDailyBudget); + const n = Math.min(maxProducts, products.length); + + if (n === 0) return []; + + // Take N cheapest products + const selectedProducts = products.slice(0, n); + + // Divide budget equally with overallocation applied + const budgetPerProduct = allocatedBudget / n; + + return selectedProducts.map((product) => ({ + productId: product.id, + salesAgentId: product.salesAgentId, + budget: budgetPerProduct, + cpm: product.floorPrice || 0, + })); + } + + async start(): Promise { + await this.server.start({ + transportType: 'stdio', + }); + } +} From 84acb1f3fc7efdc881f3b75add0d89077be476de Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 16:27:37 -0400 Subject: [PATCH 07/11] Simplify to MCP-only architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope3 platform calls media agents via MCP, not HTTP. Remove all HTTP and proxy layers - media agent IS the MCP server. Changes: - Remove HTTP-based simple-media-agent (Express) - Remove generic MCP proxy (scope3-media-agent) - Rename simple-media-agent-with-mcp โ†’ simple-media-agent - Single binary: npx simple-media-agent (MCP stdio) - Update documentation for MCP-only architecture Architecture: Scope3 Platform โ†’ MCP (stdio) โ†’ Media Agent โ†’ Scope3 API The media agent exposes MCP tools directly: - get_proposed_tactics - manage_tactic No HTTP server, no proxy, just MCP! ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ARCHITECTURE.md | 135 --------- MEDIA_AGENT_MCP.md | 121 -------- SIMPLE_MEDIA_AGENT.md | 196 ++++++------- examples/media-agent-mcp.ts | 16 -- examples/simple-media-agent.ts | 95 ------- package.json | 4 +- src/__tests__/media-agent-mcp.test.ts | 30 -- src/index.ts | 2 - src/media-agent-mcp.ts | 178 ------------ src/media-agent-server.ts | 22 -- src/simple-media-agent-mcp-server.ts | 37 --- src/simple-media-agent-server.ts | 15 +- src/simple-media-agent-with-mcp.ts | 297 ------------------- src/simple-media-agent.ts | 395 +++++++++++++------------- 14 files changed, 302 insertions(+), 1241 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 MEDIA_AGENT_MCP.md delete mode 100644 examples/media-agent-mcp.ts delete mode 100644 examples/simple-media-agent.ts delete mode 100644 src/__tests__/media-agent-mcp.test.ts delete mode 100644 src/media-agent-mcp.ts delete mode 100644 src/media-agent-server.ts delete mode 100644 src/simple-media-agent-mcp-server.ts delete mode 100644 src/simple-media-agent-with-mcp.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index ce214a4..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,135 +0,0 @@ -# Media Agent Architecture - -## Two Deployment Models - -There are **two ways** to deploy a media agent, depending on who's calling it: - -### 1. HTTP Media Agent (for Scope3 Orchestrator) - -**Use When**: The Scope3 platform orchestrator calls your media agent - -``` -Scope3 Orchestrator โ†’ HTTP POST โ†’ Media Agent (Express) โ†’ Scope3 API -``` - -**Command**: -```bash -export SCOPE3_API_KEY=your_key -npx simple-media-agent # Runs on port 8080 -``` - -**What It Does**: -- Exposes HTTP endpoints (`/get-proposed-tactics`, `/manage-tactic`, etc.) -- Waits for Scope3 platform to POST requests -- Implements Media Agent Protocol over HTTP - -**File**: `src/simple-media-agent.ts` - ---- - -### 2. MCP Media Agent (for Brand Agents) - -**Use When**: A brand agent (running in Claude/LLM) calls your media agent - -``` -Claude/Brand Agent โ†’ MCP stdio โ†’ Media Agent (MCP Server) โ†’ Scope3 API -``` - -**Command**: -```bash -export SCOPE3_API_KEY=your_key -npx simple-media-agent-mcp # MCP stdio -``` - -**What It Does**: -- Exposes MCP tools (`get_proposed_tactics`, `manage_tactic`) -- Communicates via stdio (MCP protocol) -- Brand agent can directly call tools - -**File**: `src/simple-media-agent-with-mcp.ts` - ---- - -## Why Two Models? - -### HTTP Model -- โœ… Called BY the Scope3 platform -- โœ… Receives webhooks for reporting, tactic updates -- โœ… Standard web server architecture -- โŒ Not directly callable by LLMs - -### MCP Model -- โœ… Called BY brand agents (LLMs) -- โœ… Native LLM integration -- โœ… No HTTP server needed -- โŒ Can't receive webhooks from Scope3 - ---- - -## The Generic MCP Proxy (Deprecated for Our Use Case) - -**What is `scope3-media-agent`?** - -This is a **generic MCP proxy** that forwards MCP calls to ANY media agent's HTTP endpoints: - -``` -Claude โ†’ MCP Proxy โ†’ HTTP POST โ†’ Any Media Agent -``` - -**Why it exists**: To let brand agents call media agents that only expose HTTP endpoints. - -**Why you don't need it**: If your media agent exposes MCP tools directly (`simple-media-agent-mcp`), the proxy is unnecessary! - ---- - -## Quick Decision Guide - -**Q: Who is calling my media agent?** - -- **Scope3 Platform** โ†’ Use `npx simple-media-agent` (HTTP) -- **Brand Agent/LLM** โ†’ Use `npx simple-media-agent-mcp` (MCP) -- **Third-party HTTP media agent** โ†’ Use `npx scope3-media-agent` (MCP Proxy) - ---- - -## Examples - -### Example 1: Platform Integration -```bash -# Deploy media agent that Scope3 platform can call -npx simple-media-agent -# โ†’ HTTP server on port 8080 -# โ†’ Scope3 platform POSTs to http://your-domain:8080/manage-tactic -``` - -### Example 2: Brand Agent Integration -```bash -# Brand agent running in Claude Desktop -export SCOPE3_API_KEY=your_key -npx simple-media-agent-mcp -# โ†’ MCP server on stdio -# โ†’ Brand agent calls get_proposed_tactics tool -# โ†’ No HTTP server needed! -``` - -### Example 3: Third-Party Media Agent -```bash -# Terminal 1: Some other media agent with HTTP endpoints -npx other-media-agent --port 8080 - -# Terminal 2: MCP proxy for brand agent to call it -export MEDIA_AGENT_URL=http://localhost:8080 -npx scope3-media-agent -# โ†’ Brand agent calls MCP tools -# โ†’ Proxy forwards to HTTP endpoints -``` - ---- - -## Summary - -- **`simple-media-agent`**: HTTP server for Scope3 platform -- **`simple-media-agent-mcp`**: MCP server for brand agents (NO PROXY NEEDED!) -- **`scope3-media-agent`**: Generic MCPโ†’HTTP proxy (for third-party agents) - -You were right to question the proxy! For our simple media agent, the MCP version (`simple-media-agent-mcp`) IS the MCP server directly. diff --git a/MEDIA_AGENT_MCP.md b/MEDIA_AGENT_MCP.md deleted file mode 100644 index a257548..0000000 --- a/MEDIA_AGENT_MCP.md +++ /dev/null @@ -1,121 +0,0 @@ -# Media Agent MCP Server - -MCP (Model Context Protocol) server implementation for Scope3 Media Agents, built with [fastmcp](https://www.npmjs.com/package/fastmcp). - -## Overview - -This MCP server provides tools to interact with media agents that implement the Media Agent Protocol. Media agents are autonomous systems that optimize media buying on behalf of advertisers. - -## Installation - -```bash -npm install @scope3/agentic-client -``` - -## Usage - -### As a Standalone Server - -Set environment variables and run: - -```bash -export MEDIA_AGENT_URL=https://your-media-agent.example.com -export MEDIA_AGENT_API_KEY=your_api_key -npx scope3-media-agent -``` - -### Programmatically - -```typescript -import { MediaAgentMCP } from '@scope3/agentic-client'; - -const server = new MediaAgentMCP({ - mediaAgentUrl: 'https://your-media-agent.example.com', - apiKey: process.env.MEDIA_AGENT_API_KEY, - name: 'my-media-agent', - version: '1.0.0', -}); - -await server.start(); -``` - -## Available Tools - -### `get_proposed_tactics` - -Get tactic proposals from the media agent. Called when setting up a campaign. - -**Parameters:** -- `campaignId` (required): Campaign ID -- `seatId` (required): Seat/account ID -- `budgetRange`: Budget range with min/max/currency -- `startDate`: Campaign start date (ISO 8601) -- `endDate`: Campaign end date (ISO 8601) -- `channels`: Array of channels (display, video, audio, native, ctv) -- `countries`: Array of ISO country codes -- `objectives`: Campaign objectives -- `brief`: Campaign brief text -- `acceptedPricingMethods`: Accepted pricing methods - -**Returns:** List of proposed tactics with execution plans, budget capacity, and pricing - -### `manage_tactic` - -Accept or decline tactic assignment. - -**Parameters:** -- `tacticId` (required): ID of the tactic -- `tacticContext` (required): Complete tactic details -- `brandAgentId` (required): Brand agent ID -- `seatId` (required): Seat/account ID -- `customFields`: Custom fields from advertiser - -**Returns:** Acknowledgment of assignment - -### `tactic_context_updated` - -Notification of tactic changes (budget, schedule, etc.). - -**Parameters:** -- `tacticId` (required): Tactic ID -- `tactic` (required): Current tactic state -- `patch` (required): JSON Patch format changes - -### `tactic_creatives_updated` - -Notification of creative changes. - -**Parameters:** -- `tacticId` (required): Tactic ID -- `creatives` (required): Updated creative assets -- `patch` (required): JSON Patch format changes - -### `tactic_feedback` - -Performance feedback from the orchestrator. - -**Parameters:** -- `tacticId` (required): Tactic ID -- `startDate` (required): Start of feedback interval -- `endDate` (required): End of feedback interval -- `deliveryIndex` (required): Delivery performance (100 = on target) -- `performanceIndex` (required): Performance vs target (100 = maximum) - -## Configuration - -### Environment Variables - -- `MEDIA_AGENT_URL`: URL of your media agent server (required) -- `MEDIA_AGENT_API_KEY`: API key for authentication (optional) - -## Media Agent Protocol - -The MCP server communicates with media agents that implement the [Media Agent Protocol](https://docs.agentic.scope3.com/media-agent-protocol). Your media agent must implement these endpoints: - -- `POST /get-proposed-tactics` -- `POST /manage-tactic` -- `POST /tactic-context-updated` -- `POST /tactic-creatives-updated` -- `POST /tactic-feedback` - -See the OpenAPI specification in `media-agent-openapi.yaml` for full details. diff --git a/SIMPLE_MEDIA_AGENT.md b/SIMPLE_MEDIA_AGENT.md index 609336b..5233f2e 100644 --- a/SIMPLE_MEDIA_AGENT.md +++ b/SIMPLE_MEDIA_AGENT.md @@ -1,13 +1,15 @@ # Simple Media Agent -A basic reference implementation of a media agent that implements the Media Agent Protocol. +A basic reference implementation of a media agent using MCP (Model Context Protocol). ## Overview -This simple media agent demonstrates a passthrough strategy: -1. **Get Proposed Tactics**: Fetches products from all registered sales agents and proposes budget allocation based on floor prices -2. **Manage Tactic**: When assigned, creates media buys by allocating budget to the N cheapest products -3. **Reallocation**: Responds to daily reporting signals to reallocate budget based on performance +This media agent exposes MCP tools that Scope3 platform calls to manage media buying: + +- **get_proposed_tactics**: Fetches products from sales agents and proposes budget allocation based on floor prices +- **manage_tactic**: When assigned, creates media buys by allocating budget to the N cheapest products with overallocation + +**Protocol**: MCP (stdio) - All communication via MCP, no HTTP server needed! ## Algorithm @@ -21,11 +23,6 @@ This simple media agent demonstrates a passthrough strategy: **Example**: For a $10,000 campaign with 40% overallocation, the agent allocates $14,000 across media buys. This ensures you hit your $10,000 spend target even with underdelivery. -### Reallocation -- Listens for daily `reporting-complete` webhook -- Analyzes performance data from all media buys -- Reallocates budget based on performance (TODO: implement reallocation logic) - ## Installation ```bash @@ -34,16 +31,17 @@ npm install @scope3/agentic-client ## Usage -### As a Standalone Server +### As MCP Server (Standalone) ```bash export SCOPE3_API_KEY=your_api_key -export PORT=8080 export MIN_DAILY_BUDGET=100 export OVERALLOCATION_PERCENT=40 npx simple-media-agent ``` +The agent runs as an MCP server on stdio. Scope3 platform calls it via MCP protocol. + ### Programmatically ```typescript @@ -52,12 +50,13 @@ import { SimpleMediaAgent } from '@scope3/agentic-client'; const agent = new SimpleMediaAgent({ scope3ApiKey: process.env.SCOPE3_API_KEY, scope3BaseUrl: 'https://api.agentic.scope3.com', - port: 8080, minDailyBudget: 100, overallocationPercent: 40, + name: 'my-media-agent', + version: '1.0.0', }); -agent.start(); +await agent.start(); ``` ## Configuration @@ -66,132 +65,137 @@ agent.start(); - `SCOPE3_API_KEY` (required): Your Scope3 API key - `SCOPE3_BASE_URL` (optional): Scope3 API base URL (default: https://api.agentic.scope3.com) -- `PORT` (optional): Port to listen on (default: 8080) - `MIN_DAILY_BUDGET` (optional): Minimum daily budget per product in USD (default: 100) - `OVERALLOCATION_PERCENT` (optional): Overallocation percentage to ensure delivery (default: 40) -## Endpoints +## MCP Tools -The agent implements all required Media Agent Protocol endpoints: +The agent exposes two MCP tools: -### POST /get-proposed-tactics -Returns tactic proposals based on available products and floor prices. +### `get_proposed_tactics` -**Request:** -```json -{ - "campaignId": "campaign-123", - "budgetRange": { "min": 10000, "max": 50000, "currency": "USD" }, - "channels": ["display", "video"], - "countries": ["US", "CA"], - "brief": "Campaign brief text", - "seatId": "seat-123" -} -``` +Get tactic proposals based on available products and floor prices. -**Response:** +**Parameters:** +- `campaignId` (required): Campaign ID +- `seatId` (required): Seat/account ID +- `budgetRange` (optional): Budget range with min/max/currency +- `channels` (optional): Array of media channels +- `countries` (optional): Array of ISO country codes + +**Returns:** ```json { "proposedTactics": [ { "tacticId": "simple-passthrough-campaign-123", - "execution": "Passthrough strategy: distribute budget across N products", + "execution": "Passthrough strategy with 40% overallocation...", "budgetCapacity": 50000, "pricing": { "method": "passthrough", "estimatedCpm": 2.50, "currency": "USD" }, - "sku": "simple-passthrough" + "metadata": { + "productCount": 25, + "avgFloorPrice": 2.50, + "overallocationPercent": 40 + } } ] } ``` -### POST /manage-tactic -Accepts tactic assignment and creates media buys. - -### POST /tactic-context-updated -Handles tactic context changes (budget, schedule, etc.). +### `manage_tactic` -### POST /tactic-creatives-updated -Handles creative updates. +Accept tactic assignment and create media buys. -### POST /tactic-feedback -Receives performance feedback from orchestrator. +**Parameters:** +- `tacticId` (required): Tactic ID from proposal +- `tacticContext` (required): Tactic details including budget +- `brandAgentId` (required): Brand agent ID +- `seatId` (required): Seat/account ID -### POST /webhook/reporting-complete -Handles daily reporting complete signal and triggers reallocation. - -**Request:** +**Returns:** ```json { - "tacticId": "tactic-123", - "reportingData": { - "date": "2025-10-20", - "impressions": 100000, - "spend": 250.00 - } + "acknowledged": true, + "mediaBuysCreated": 5, + "allocations": [ + { + "productId": "prod-123", + "budget": 2800, + "cpm": 2.10 + } + ] } ``` -## Connecting to the MCP Server +## Architecture -To use this agent with the MCP server: - -1. Start the simple media agent: - ```bash - export SCOPE3_API_KEY=your_api_key - npx simple-media-agent - ``` - -2. Start the MCP server pointing to the media agent: - ```bash - export MEDIA_AGENT_URL=http://localhost:8080 - npx scope3-media-agent - ``` +``` +Scope3 Platform โ†’ MCP (stdio) โ†’ Simple Media Agent โ†’ Scope3 API + โ†“ + Create Media Buys +``` -3. The MCP server will now proxy calls to your simple media agent +The agent: +1. Receives MCP tool calls from Scope3 platform +2. Fetches products from sales agents via Scope3 API +3. Calculates budget allocation with overallocation +4. Creates media buys via Scope3 API +5. Returns results via MCP response ## Extending the Agent -This is a reference implementation. To build your own media agent: +This is a reference implementation. To build your own: -1. **Custom Product Selection**: Modify `handleGetProposedTactics` to filter/rank products differently -2. **Budget Allocation**: Update `calculateBudgetAllocation` with your own algorithm -3. **Reallocation Logic**: Implement performance-based reallocation in `handleReportingComplete` -4. **Optimization**: Add ML models, historical data analysis, or custom signals - -## Example Custom Extensions - -### Use Brand Story for Targeting -```typescript -const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - brandStoryId: tacticContext.brandStoryId, -}); -``` - -### Optimize for vCPM +### Custom Product Selection ```typescript // Filter products by viewability const highViewabilityProducts = allProducts.filter(p => - p.viewability >= 0.85 + p.metadata?.viewability >= 0.85 ); ``` -### Performance-Based Reallocation +### Custom Budget Allocation ```typescript -// In handleReportingComplete -const sortedByPerformance = mediaBuys.sort((a, b) => - b.performanceIndex - a.performanceIndex -); +// Weight by performance, not just equal division +const budgetPerProduct = allocatedBudget * product.performanceScore / totalScore; +``` -// Increase budget for top performers -for (const mediaBuy of sortedByPerformance.slice(0, 3)) { - await this.scope3.mediaBuys.update({ - mediaBuyId: mediaBuy.id, - budget: { amount: mediaBuy.budget * 1.2 }, - }); +### Add More Tools +```typescript +this.server.addTool({ + name: 'reallocate_budget', + description: 'Reallocate budget based on performance', + parameters: z.object({ + tacticId: z.string(), + performanceData: z.object({}).passthrough(), + }), + execute: async (args) => { + // Custom reallocation logic + }, +}); +``` + +## Example: Claude Desktop Configuration + +Add to your Claude Desktop MCP config: + +```json +{ + "mcpServers": { + "simple-media-agent": { + "command": "npx", + "args": ["simple-media-agent"], + "env": { + "SCOPE3_API_KEY": "your_key", + "OVERALLOCATION_PERCENT": "40" + } + } + } } ``` + +Then in Claude: "Use get_proposed_tactics to propose tactics for campaign XYZ with $50,000 budget" diff --git a/examples/media-agent-mcp.ts b/examples/media-agent-mcp.ts deleted file mode 100644 index dcc64cc..0000000 --- a/examples/media-agent-mcp.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MediaAgentMCP } from '../src/media-agent-mcp'; - -// Example: Running the media agent MCP server -// This shows how to programmatically create and run the MCP server - -const server = new MediaAgentMCP({ - mediaAgentUrl: 'https://your-media-agent.example.com', - apiKey: process.env.MEDIA_AGENT_API_KEY, - name: 'example-media-agent', - version: '1.0.0', -}); - -server.start().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); diff --git a/examples/simple-media-agent.ts b/examples/simple-media-agent.ts deleted file mode 100644 index 33021e7..0000000 --- a/examples/simple-media-agent.ts +++ /dev/null @@ -1,95 +0,0 @@ -import express from 'express'; -import type { Request, Response } from 'express'; - -/** - * Example: Simple Media Agent Implementation - * - * This demonstrates a basic media agent that implements the Media Agent Protocol. - * In production, you would add your optimization logic, data analysis, etc. - */ - -const app = express(); -app.use(express.json()); - -// POST /get-proposed-tactics -app.post('/get-proposed-tactics', (req: Request, res: Response) => { - const { budgetRange } = req.body; - - // In production, analyze the campaign and propose tactics - const proposedTactics = [ - { - tacticId: 'premium-display-tactic', - execution: 'Target premium display inventory with 85% viewability', - budgetCapacity: budgetRange?.max ? budgetRange.max * 0.5 : 50000, - pricing: { - method: 'revshare', - rate: 0.15, - currency: 'USD', - }, - sku: 'premium-display', - customFieldsRequired: [ - { - fieldName: 'targetVCPM', - fieldType: 'number', - description: 'Target viewable CPM in USD', - }, - ], - }, - ]; - - res.json({ proposedTactics }); -}); - -// POST /manage-tactic -app.post('/manage-tactic', (req: Request, res: Response) => { - const { tacticId, tacticContext, customFields } = req.body; - - console.log(`Managing tactic ${tacticId}`, { tacticContext, customFields }); - - // In production, set up targeting, create media buys, etc. - - res.json({ - acknowledged: true, - }); -}); - -// POST /tactic-context-updated -app.post('/tactic-context-updated', (req: Request, res: Response) => { - const { tacticId, patch } = req.body; - - console.log(`Tactic ${tacticId} updated:`, patch); - - // In production, adjust media buys based on changes - - res.json({ acknowledged: true }); -}); - -// POST /tactic-creatives-updated -app.post('/tactic-creatives-updated', (req: Request, res: Response) => { - const { tacticId, patch } = req.body; - - console.log(`Creatives for tactic ${tacticId} updated:`, patch); - - // In production, update media buys with new creatives - - res.json({ acknowledged: true }); -}); - -// POST /tactic-feedback -app.post('/tactic-feedback', (req: Request, res: Response) => { - const { tacticId, deliveryIndex, performanceIndex } = req.body; - - console.log(`Feedback for tactic ${tacticId}:`, { - deliveryIndex, - performanceIndex, - }); - - // In production, optimize based on feedback - - res.json({ acknowledged: true }); -}); - -const PORT = process.env.PORT || 8080; -app.listen(PORT, () => { - console.log(`Simple media agent listening on port ${PORT}`); -}); diff --git a/package.json b/package.json index 7f901f6..8dd5637 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "scope3-media-agent": "dist/media-agent-server.js", - "simple-media-agent": "dist/simple-media-agent-server.js", - "simple-media-agent-mcp": "dist/simple-media-agent-mcp-server.js" + "simple-media-agent": "dist/simple-media-agent-server.js" }, "scripts": { "build": "npm run type-check && tsc", diff --git a/src/__tests__/media-agent-mcp.test.ts b/src/__tests__/media-agent-mcp.test.ts deleted file mode 100644 index 3dae9bb..0000000 --- a/src/__tests__/media-agent-mcp.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Mock fastmcp to avoid ESM import issues in Jest -jest.mock('fastmcp', () => ({ - FastMCP: jest.fn().mockImplementation(() => ({ - addTool: jest.fn(), - start: jest.fn().mockResolvedValue(undefined), - })), -})); - -import { MediaAgentMCP } from '../media-agent-mcp'; - -describe('MediaAgentMCP', () => { - it('should create an instance with required config', () => { - const mcp = new MediaAgentMCP({ - mediaAgentUrl: 'https://example.com', - }); - - expect(mcp).toBeInstanceOf(MediaAgentMCP); - }); - - it('should create an instance with full config', () => { - const mcp = new MediaAgentMCP({ - mediaAgentUrl: 'https://example.com', - apiKey: 'test-key', - name: 'test-agent', - version: '1.0.0', - }); - - expect(mcp).toBeInstanceOf(MediaAgentMCP); - }); -}); diff --git a/src/index.ts b/src/index.ts index c1aaa47..f52a429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,9 @@ export { Scope3AgenticClient } from './sdk'; // Legacy export for backwards compatibility export { Scope3AgenticClient as Scope3SDK } from './sdk'; export { WebhookServer } from './webhook-server'; -export { MediaAgentMCP } from './media-agent-mcp'; export { SimpleMediaAgent } from './simple-media-agent'; export type { ClientConfig, ToolResponse, Environment } from './types'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; -export type { MediaAgentMCPConfig } from './media-agent-mcp'; export * from './resources/assets'; export * from './resources/brand-agents'; diff --git a/src/media-agent-mcp.ts b/src/media-agent-mcp.ts deleted file mode 100644 index 34479be..0000000 --- a/src/media-agent-mcp.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { FastMCP } from 'fastmcp'; -import { z } from 'zod'; - -export interface MediaAgentMCPConfig { - name?: string; - version?: string; - mediaAgentUrl: string; - apiKey?: string; -} - -export class MediaAgentMCP { - private server: FastMCP; - private config: MediaAgentMCPConfig; - - constructor(config: MediaAgentMCPConfig) { - this.config = config; - this.server = new FastMCP({ - name: config.name || 'media-agent-mcp', - version: (config.version as `${number}.${number}.${number}`) || '1.0.0', - }); - - this.setupTools(); - } - - private async callMediaAgent(endpoint: string, body: unknown): Promise { - const url = `${this.config.mediaAgentUrl}${endpoint}`; - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (this.config.apiKey) { - headers['X-API-Key'] = this.config.apiKey; - } - - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Media agent request failed: ${response.status} ${response.statusText}`); - } - - return response.json(); - } - - private setupTools(): void { - // get_proposed_tactics tool - this.server.addTool({ - name: 'get_proposed_tactics', - description: - 'Get tactic proposals from the media agent. Called when setting up a campaign to ask what tactics the agent can handle.', - parameters: z.object({ - campaignId: z.string().describe('Campaign ID'), - budgetRange: z - .object({ - min: z.number(), - max: z.number(), - currency: z.string(), - }) - .optional(), - startDate: z.string().optional().describe('Campaign start date (ISO 8601)'), - endDate: z.string().optional().describe('Campaign end date (ISO 8601)'), - channels: z - .array(z.enum(['display', 'video', 'audio', 'native', 'ctv'])) - .optional() - .describe('Media channels'), - countries: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2 country codes'), - objectives: z.array(z.string()).optional().describe('Campaign objectives'), - brief: z.string().optional().describe('Campaign brief text'), - acceptedPricingMethods: z - .array(z.enum(['cpm', 'vcpm', 'cpc', 'cpcv', 'cpv', 'cpp', 'flat_rate'])) - .optional() - .describe('Accepted pricing methods'), - seatId: z.string().describe('Seat/account ID'), - }), - execute: async (args) => { - const response = await this.callMediaAgent('/get-proposed-tactics', args); - return JSON.stringify(response, null, 2); - }, - }); - - // manage_tactic tool - this.server.addTool({ - name: 'manage_tactic', - description: - 'Accept or decline tactic assignment. Called when the agent is assigned to manage a tactic.', - parameters: z.object({ - tacticId: z.string().describe('ID of the tactic'), - tacticContext: z.object({}).passthrough().describe('Complete tactic details'), - brandAgentId: z.string().describe('Brand agent ID'), - seatId: z.string().describe('Seat/account ID'), - customFields: z - .object({}) - .passthrough() - .optional() - .describe('Custom fields from advertiser'), - }), - execute: async (args) => { - const response = await this.callMediaAgent('/manage-tactic', args); - return JSON.stringify(response, null, 2); - }, - }); - - // tactic_context_updated tool - this.server.addTool({ - name: 'tactic_context_updated', - description: - 'Notification of tactic changes. MUST be handled as changes may impact targeting or budget.', - parameters: z.object({ - tacticId: z.string().describe('Tactic ID'), - tactic: z.object({}).passthrough().describe('Current tactic state'), - patch: z - .array( - z.object({ - op: z.enum(['add', 'remove', 'replace']), - path: z.string(), - value: z.unknown().optional(), - }) - ) - .describe('JSON Patch format changes'), - }), - execute: async (args) => { - await this.callMediaAgent('/tactic-context-updated', args); - return 'Tactic context update sent successfully'; - }, - }); - - // tactic_creatives_updated tool - this.server.addTool({ - name: 'tactic_creatives_updated', - description: - 'Notification of creative changes. Update media buys to use new creative assets.', - parameters: z.object({ - tacticId: z.string().describe('Tactic ID'), - creatives: z.array(z.unknown()).describe('Updated creative assets'), - patch: z - .array( - z.object({ - op: z.enum(['add', 'remove', 'replace']), - path: z.string(), - value: z.unknown().optional(), - }) - ) - .describe('JSON Patch format changes'), - }), - execute: async (args) => { - await this.callMediaAgent('/tactic-creatives-updated', args); - return 'Tactic creatives update sent successfully'; - }, - }); - - // tactic_feedback tool - this.server.addTool({ - name: 'tactic_feedback', - description: - 'Performance feedback from orchestrator. MAY trigger updates to improve performance.', - parameters: z.object({ - tacticId: z.string().describe('Tactic ID'), - startDate: z.string().describe('Start of feedback interval (ISO 8601)'), - endDate: z.string().describe('End of feedback interval (ISO 8601)'), - deliveryIndex: z.number().describe('Delivery performance (100 = on target)'), - performanceIndex: z.number().describe('Performance vs target (100 = maximum)'), - }), - execute: async (args) => { - await this.callMediaAgent('/tactic-feedback', args); - return 'Tactic feedback sent successfully'; - }, - }); - } - - async start(): Promise { - await this.server.start({ - transportType: 'stdio', - }); - } -} diff --git a/src/media-agent-server.ts b/src/media-agent-server.ts deleted file mode 100644 index d0d17ff..0000000 --- a/src/media-agent-server.ts +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import { MediaAgentMCP } from './media-agent-mcp.js'; - -const mediaAgentUrl = process.env.MEDIA_AGENT_URL; -const apiKey = process.env.MEDIA_AGENT_API_KEY; - -if (!mediaAgentUrl) { - console.error('Error: MEDIA_AGENT_URL environment variable is required'); - process.exit(1); -} - -const server = new MediaAgentMCP({ - mediaAgentUrl, - apiKey, - name: 'scope3-media-agent', - version: '1.0.0', -}); - -server.start().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); diff --git a/src/simple-media-agent-mcp-server.ts b/src/simple-media-agent-mcp-server.ts deleted file mode 100644 index 3cbb933..0000000 --- a/src/simple-media-agent-mcp-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -import { SimpleMediaAgentMCP } from './simple-media-agent-with-mcp.js'; - -const scope3ApiKey = process.env.SCOPE3_API_KEY; -const scope3BaseUrl = process.env.SCOPE3_BASE_URL; -const minDailyBudget = process.env.MIN_DAILY_BUDGET - ? parseFloat(process.env.MIN_DAILY_BUDGET) - : 100; -const overallocationPercent = process.env.OVERALLOCATION_PERCENT - ? parseFloat(process.env.OVERALLOCATION_PERCENT) - : 40; - -if (!scope3ApiKey) { - console.error('Error: SCOPE3_API_KEY environment variable is required'); - process.exit(1); -} - -const agent = new SimpleMediaAgentMCP({ - scope3ApiKey, - scope3BaseUrl, - minDailyBudget, - overallocationPercent, - name: 'simple-media-agent', - version: '1.0.0', -}); - -agent.start().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); - -console.error(` -Simple Media Agent MCP Server -- Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} -- Min Daily Budget: $${minDailyBudget} -- Overallocation: ${overallocationPercent}% -`); diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts index 4b878b4..e69e691 100644 --- a/src/simple-media-agent-server.ts +++ b/src/simple-media-agent-server.ts @@ -3,7 +3,6 @@ import { SimpleMediaAgent } from './simple-media-agent.js'; const scope3ApiKey = process.env.SCOPE3_API_KEY; const scope3BaseUrl = process.env.SCOPE3_BASE_URL; -const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; const minDailyBudget = process.env.MIN_DAILY_BUDGET ? parseFloat(process.env.MIN_DAILY_BUDGET) : 100; @@ -19,17 +18,21 @@ if (!scope3ApiKey) { const agent = new SimpleMediaAgent({ scope3ApiKey, scope3BaseUrl, - port, minDailyBudget, overallocationPercent, + name: 'simple-media-agent', + version: '1.0.0', }); -agent.start(); +agent.start().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); -console.log(` -Simple Media Agent Configuration: -- Port: ${port} +console.error(` +Simple Media Agent - Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} - Min Daily Budget: $${minDailyBudget} - Overallocation: ${overallocationPercent}% +- Protocol: MCP (stdio) `); diff --git a/src/simple-media-agent-with-mcp.ts b/src/simple-media-agent-with-mcp.ts deleted file mode 100644 index a81dcd9..0000000 --- a/src/simple-media-agent-with-mcp.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { FastMCP } from 'fastmcp'; -import { z } from 'zod'; -import { Scope3AgenticClient } from './sdk'; - -interface SimpleMediaAgentMCPConfig { - scope3ApiKey: string; - scope3BaseUrl?: string; - minDailyBudget?: number; - overallocationPercent?: number; - name?: string; - version?: string; -} - -interface Product { - id: string; - salesAgentId: string; - floorPrice?: number; - recommendedPrice?: number; - name?: string; -} - -interface MediaBuyAllocation { - productId: string; - salesAgentId: string; - budget: number; - cpm: number; -} - -/** - * Simple Media Agent that exposes MCP tools directly - * This allows brand agents to interact with it via MCP protocol - */ -export class SimpleMediaAgentMCP { - private server: FastMCP; - private scope3: Scope3AgenticClient; - private config: Required< - Omit & { name: string; version: string } - >; - private activeTactics: Map; - - constructor(config: SimpleMediaAgentMCPConfig) { - this.config = { - scope3ApiKey: config.scope3ApiKey, - scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', - minDailyBudget: config.minDailyBudget || 100, - overallocationPercent: config.overallocationPercent || 40, - name: config.name || 'simple-media-agent', - version: (config.version as `${number}.${number}.${number}`) || '1.0.0', - }; - - this.server = new FastMCP({ - name: this.config.name, - version: this.config.version as `${number}.${number}.${number}`, - }); - - this.scope3 = new Scope3AgenticClient({ - apiKey: this.config.scope3ApiKey, - baseUrl: this.config.scope3BaseUrl, - }); - - this.activeTactics = new Map(); - this.setupTools(); - } - - private setupTools(): void { - // get_proposed_tactics tool - this.server.addTool({ - name: 'get_proposed_tactics', - description: - 'Get tactic proposals from this media agent. Returns budget allocation based on floor prices from sales agents.', - parameters: z.object({ - campaignId: z.string().describe('Campaign ID'), - budgetRange: z - .object({ - min: z.number(), - max: z.number(), - currency: z.string(), - }) - .optional(), - channels: z.array(z.string()).optional().describe('Media channels'), - countries: z.array(z.string()).optional().describe('ISO country codes'), - seatId: z.string().describe('Seat/account ID'), - }), - execute: async (args) => { - const result = await this.handleGetProposedTactics(args); - return JSON.stringify(result, null, 2); - }, - }); - - // manage_tactic tool - this.server.addTool({ - name: 'manage_tactic', - description: - 'Assign this media agent to manage a tactic. Creates media buys with overallocated budgets.', - parameters: z.object({ - tacticId: z.string().describe('Tactic ID'), - tacticContext: z.object({}).passthrough().describe('Tactic details including budget'), - brandAgentId: z.string().describe('Brand agent ID'), - seatId: z.string().describe('Seat/account ID'), - }), - execute: async (args) => { - const result = await this.handleManageTactic(args); - return JSON.stringify(result, null, 2); - }, - }); - } - - private async handleGetProposedTactics(args: { - campaignId: string; - budgetRange?: { min: number; max: number; currency: string }; - seatId: string; - }): Promise { - const { campaignId, budgetRange } = args; - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Call getProducts for each sales agent - const allProducts: Product[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate average floor price for proposal - const avgFloorPrice = - allProducts.length > 0 - ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length - : 0; - - return { - proposedTactics: [ - { - tacticId: `simple-passthrough-${campaignId}`, - execution: `Passthrough strategy with ${this.config.overallocationPercent}% overallocation: distribute budget across ${allProducts.length} products based on floor prices`, - budgetCapacity: budgetRange?.max || 0, - pricing: { - method: 'passthrough', - estimatedCpm: avgFloorPrice, - currency: 'USD', - }, - sku: 'simple-passthrough', - metadata: { - productCount: allProducts.length, - avgFloorPrice, - overallocationPercent: this.config.overallocationPercent, - }, - }, - ], - }; - } - - private async handleManageTactic(args: { - tacticId: string; - tacticContext: { budget?: { amount: number } }; - brandAgentId: string; - seatId: string; - }): Promise { - const { tacticId, tacticContext } = args; - - console.log(`Managing tactic ${tacticId}`); - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Get products from all sales agents - const allProducts: Product[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate budget allocation - const totalBudget = tacticContext.budget?.amount || 0; - const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); - - // Store tactic info for later reallocation - this.activeTactics.set(tacticId, { - products: allProducts, - allocations, - }); - - // Create media buys for each allocation - const createdBuys = []; - for (const allocation of allocations) { - try { - const result = await this.scope3.mediaBuys.create({ - tacticId, - name: `Media Buy - ${allocation.productId}`, - products: [ - { - mediaProductId: allocation.productId, - salesAgentId: allocation.salesAgentId, - pricingCpm: allocation.cpm, - }, - ], - budget: { - amount: allocation.budget, - currency: 'USD', - }, - }); - createdBuys.push(result.data); - } catch (error) { - console.error(`Error creating media buy for product ${allocation.productId}:`, error); - } - } - - return { - acknowledged: true, - mediaBuysCreated: createdBuys.length, - allocations: allocations.map((a) => ({ - productId: a.productId, - budget: a.budget, - cpm: a.cpm, - })), - }; - } - - private calculateBudgetAllocation( - products: Product[], - totalBudget: number - ): MediaBuyAllocation[] { - if (products.length === 0) return []; - - // Apply overallocation to ensure delivery - const overallocationMultiplier = 1 + this.config.overallocationPercent / 100; - const allocatedBudget = totalBudget * overallocationMultiplier; - - // Calculate N = number of products where daily budget >= minDailyBudget - const assumedDays = 30; - const maxProducts = Math.floor(allocatedBudget / assumedDays / this.config.minDailyBudget); - const n = Math.min(maxProducts, products.length); - - if (n === 0) return []; - - // Take N cheapest products - const selectedProducts = products.slice(0, n); - - // Divide budget equally with overallocation applied - const budgetPerProduct = allocatedBudget / n; - - return selectedProducts.map((product) => ({ - productId: product.id, - salesAgentId: product.salesAgentId, - budget: budgetPerProduct, - cpm: product.floorPrice || 0, - })); - } - - async start(): Promise { - await this.server.start({ - transportType: 'stdio', - }); - } -} diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts index 6bda935..8be8bf4 100644 --- a/src/simple-media-agent.ts +++ b/src/simple-media-agent.ts @@ -1,13 +1,14 @@ -import express from 'express'; -import type { Request, Response } from 'express'; +import { FastMCP } from 'fastmcp'; +import { z } from 'zod'; import { Scope3AgenticClient } from './sdk'; -interface SimpleMediaAgentConfig { - port?: number; +interface SimpleMediaAgentMCPConfig { scope3ApiKey: string; scope3BaseUrl?: string; minDailyBudget?: number; overallocationPercent?: number; + name?: string; + version?: string; } interface Product { @@ -25,86 +26,134 @@ interface MediaBuyAllocation { cpm: number; } +/** + * Simple Media Agent that exposes MCP tools + * Called by Scope3 platform via MCP protocol + */ export class SimpleMediaAgent { - private app: express.Application; - private config: Required; + private server: FastMCP; private scope3: Scope3AgenticClient; + private config: Required< + Omit & { name: string; version: string } + >; private activeTactics: Map; - constructor(config: SimpleMediaAgentConfig) { + constructor(config: SimpleMediaAgentMCPConfig) { this.config = { - port: config.port || 8080, scope3ApiKey: config.scope3ApiKey, scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', minDailyBudget: config.minDailyBudget || 100, overallocationPercent: config.overallocationPercent || 40, + name: config.name || 'simple-media-agent', + version: (config.version as `${number}.${number}.${number}`) || '1.0.0', }; + this.server = new FastMCP({ + name: this.config.name, + version: this.config.version as `${number}.${number}.${number}`, + }); + this.scope3 = new Scope3AgenticClient({ apiKey: this.config.scope3ApiKey, baseUrl: this.config.scope3BaseUrl, }); this.activeTactics = new Map(); - this.app = express(); - this.app.use(express.json()); - this.setupRoutes(); + this.setupTools(); } - private setupRoutes(): void { - this.app.post('/get-proposed-tactics', this.handleGetProposedTactics.bind(this)); - this.app.post('/manage-tactic', this.handleManageTactic.bind(this)); - this.app.post('/tactic-context-updated', this.handleTacticContextUpdated.bind(this)); - this.app.post('/tactic-creatives-updated', this.handleTacticCreativesUpdated.bind(this)); - this.app.post('/tactic-feedback', this.handleTacticFeedback.bind(this)); - this.app.post('/webhook/reporting-complete', this.handleReportingComplete.bind(this)); + private setupTools(): void { + // get_proposed_tactics tool + this.server.addTool({ + name: 'get_proposed_tactics', + description: + 'Get tactic proposals from this media agent. Returns budget allocation based on floor prices from sales agents.', + parameters: z.object({ + campaignId: z.string().describe('Campaign ID'), + budgetRange: z + .object({ + min: z.number(), + max: z.number(), + currency: z.string(), + }) + .optional(), + channels: z.array(z.string()).optional().describe('Media channels'), + countries: z.array(z.string()).optional().describe('ISO country codes'), + seatId: z.string().describe('Seat/account ID'), + }), + execute: async (args) => { + const result = await this.handleGetProposedTactics(args); + return JSON.stringify(result, null, 2); + }, + }); + + // manage_tactic tool + this.server.addTool({ + name: 'manage_tactic', + description: + 'Assign this media agent to manage a tactic. Creates media buys with overallocated budgets.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + tacticContext: z.object({}).passthrough().describe('Tactic details including budget'), + brandAgentId: z.string().describe('Brand agent ID'), + seatId: z.string().describe('Seat/account ID'), + }), + execute: async (args) => { + const result = await this.handleManageTactic(args); + return JSON.stringify(result, null, 2); + }, + }); } - private async handleGetProposedTactics(req: Request, res: Response): Promise { - try { - const { campaignId, budgetRange } = req.body; - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Call getProducts for each sales agent with the brief - const allProducts: Product[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } + private async handleGetProposedTactics(args: { + campaignId: string; + budgetRange?: { min: number; max: number; currency: string }; + seatId: string; + }): Promise { + const { campaignId, budgetRange } = args; + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Call getProducts for each sales agent + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); } + } - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - // Calculate average floor price for proposal - const avgFloorPrice = - allProducts.length > 0 - ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length - : 0; + // Calculate average floor price for proposal + const avgFloorPrice = + allProducts.length > 0 + ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length + : 0; - const proposedTactics = [ + return { + proposedTactics: [ { tacticId: `simple-passthrough-${campaignId}`, - execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices`, + execution: `Passthrough strategy with ${this.config.overallocationPercent}% overallocation: distribute budget across ${allProducts.length} products based on floor prices`, budgetCapacity: budgetRange?.max || 0, pricing: { method: 'passthrough', @@ -115,95 +164,98 @@ export class SimpleMediaAgent { metadata: { productCount: allProducts.length, avgFloorPrice, + overallocationPercent: this.config.overallocationPercent, }, }, - ]; - - res.json({ proposedTactics }); - } catch (error) { - console.error('Error in get-proposed-tactics:', error); - res.status(500).json({ error: 'Failed to generate tactic proposals' }); - } + ], + }; } - private async handleManageTactic(req: Request, res: Response): Promise { - try { - const { tacticId, tacticContext } = req.body; - - console.log(`Managing tactic ${tacticId}`); - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Get products from all sales agents - const allProducts: Product[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } + private async handleManageTactic(args: { + tacticId: string; + tacticContext: { budget?: { amount: number } }; + brandAgentId: string; + seatId: string; + }): Promise { + const { tacticId, tacticContext } = args; + + console.log(`Managing tactic ${tacticId}`); + + // Get all registered sales agents + const salesAgentsResponse = await this.scope3.salesAgents.list(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const salesAgents = (salesAgentsResponse.data as any[]) || []; + + // Get products from all sales agents + const allProducts: Product[] = []; + for (const agent of salesAgents) { + try { + const productsResponse = await this.scope3.products.discover({ + salesAgentId: agent.id, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...products); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); } + } - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate budget allocation - const totalBudget = tacticContext.budget?.amount || 0; - const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); - - // Store tactic info for later reallocation - this.activeTactics.set(tacticId, { - products: allProducts, - allocations, - }); - - // Create media buys for each allocation - for (const allocation of allocations) { - try { - await this.scope3.mediaBuys.create({ - tacticId, - name: `Media Buy - ${allocation.productId}`, - products: [ - { - mediaProductId: allocation.productId, - salesAgentId: allocation.salesAgentId, - pricingCpm: allocation.cpm, - }, - ], - budget: { - amount: allocation.budget, - currency: 'USD', + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate budget allocation + const totalBudget = tacticContext.budget?.amount || 0; + const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); + + // Store tactic info for later reallocation + this.activeTactics.set(tacticId, { + products: allProducts, + allocations, + }); + + // Create media buys for each allocation + const createdBuys = []; + for (const allocation of allocations) { + try { + const result = await this.scope3.mediaBuys.create({ + tacticId, + name: `Media Buy - ${allocation.productId}`, + products: [ + { + mediaProductId: allocation.productId, + salesAgentId: allocation.salesAgentId, + pricingCpm: allocation.cpm, }, - }); - } catch (error) { - console.error(`Error creating media buy for product ${allocation.productId}:`, error); - } + ], + budget: { + amount: allocation.budget, + currency: 'USD', + }, + }); + createdBuys.push(result.data); + } catch (error) { + console.error(`Error creating media buy for product ${allocation.productId}:`, error); } - - res.json({ - acknowledged: true, - mediaBuysCreated: allocations.length, - }); - } catch (error) { - console.error('Error in manage-tactic:', error); - res.status(500).json({ error: 'Failed to manage tactic' }); } + + return { + acknowledged: true, + mediaBuysCreated: createdBuys.length, + allocations: allocations.map((a) => ({ + productId: a.productId, + budget: a.budget, + cpm: a.cpm, + })), + }; } private calculateBudgetAllocation( @@ -213,12 +265,10 @@ export class SimpleMediaAgent { if (products.length === 0) return []; // Apply overallocation to ensure delivery - // If we want to spend $100/day, allocate $140/day to hit the target const overallocationMultiplier = 1 + this.config.overallocationPercent / 100; const allocatedBudget = totalBudget * overallocationMultiplier; // Calculate N = number of products where daily budget >= minDailyBudget - // Assume campaign runs for 30 days for this calculation const assumedDays = 30; const maxProducts = Math.floor(allocatedBudget / assumedDays / this.config.minDailyBudget); const n = Math.min(maxProducts, products.length); @@ -239,70 +289,9 @@ export class SimpleMediaAgent { })); } - private async handleTacticContextUpdated(req: Request, res: Response): Promise { - const { tacticId, patch } = req.body; - console.log(`Tactic ${tacticId} context updated:`, patch); - - // Check if budget changed - const budgetChange = patch.find((p: { path: string }) => p.path.startsWith('/budget')); - if (budgetChange && this.activeTactics.has(tacticId)) { - console.log(`Budget changed for tactic ${tacticId}, will reallocate on next reporting cycle`); - } - - res.json({ acknowledged: true }); - } - - private async handleTacticCreativesUpdated(req: Request, res: Response): Promise { - const { tacticId, patch } = req.body; - console.log(`Creatives for tactic ${tacticId} updated:`, patch); - - // Update media buys with new creatives if needed - res.json({ acknowledged: true }); - } - - private async handleTacticFeedback(req: Request, res: Response): Promise { - const { tacticId, deliveryIndex, performanceIndex } = req.body; - console.log(`Feedback for tactic ${tacticId}:`, { deliveryIndex, performanceIndex }); - - res.json({ acknowledged: true }); - } - - private async handleReportingComplete(req: Request, res: Response): Promise { - try { - const { tacticId, reportingData } = req.body; - console.log(`Daily reporting complete for tactic ${tacticId}`); - - if (!this.activeTactics.has(tacticId)) { - res.json({ acknowledged: true, message: 'Tactic not found' }); - return; - } - - // Get current media buys and their performance - const mediaBuysResponse = await this.scope3.mediaBuys.list({ tacticId }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mediaBuys = (mediaBuysResponse.data as any[]) || []; - - console.log(`Reallocating budget for ${mediaBuys.length} media buys`); - - // Analyze performance and reallocate - // For now, just log - in production, you'd implement reallocation logic here - for (const mediaBuy of mediaBuys) { - console.log(`Media buy ${mediaBuy.id}: performance data`, reportingData); - } - - res.json({ - acknowledged: true, - message: 'Reallocation triggered', - }); - } catch (error) { - console.error('Error in reporting-complete:', error); - res.status(500).json({ error: 'Failed to process reporting data' }); - } - } - - start(): void { - this.app.listen(this.config.port, () => { - console.log(`Simple media agent listening on port ${this.config.port}`); + async start(): Promise { + await this.server.start({ + transportType: 'stdio', }); } } From 7b8fa9d97ca08b0905c50d9a0b8fa91a329b0ce0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 20:36:19 -0400 Subject: [PATCH 08/11] Use ADCP client types and move overallocation to per-media-buy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code review comments: 1. Overallocation is now per media buy (not global config) 2. Removed unused name/version config fields 3. Use MediaBuyProduct from ADCP client library 4. MediaBuyAllocation extends MediaBuyProduct Changes: - Remove overallocationPercent from config - Each MediaBuyAllocation has its own overallocationPercent (40%) - Use MediaBuyProduct interface from ./resources/media-buys - Pass full allocation (MediaBuyProduct) when creating media buys - Update documentation to reflect per-buy overallocation This allows per-buy customization and better delivery control. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/simple-media-agent-server.ts | 6 +-- src/simple-media-agent.ts | 86 ++++++++++++++------------------ 2 files changed, 39 insertions(+), 53 deletions(-) diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts index e69e691..f168a8a 100644 --- a/src/simple-media-agent-server.ts +++ b/src/simple-media-agent-server.ts @@ -6,9 +6,6 @@ const scope3BaseUrl = process.env.SCOPE3_BASE_URL; const minDailyBudget = process.env.MIN_DAILY_BUDGET ? parseFloat(process.env.MIN_DAILY_BUDGET) : 100; -const overallocationPercent = process.env.OVERALLOCATION_PERCENT - ? parseFloat(process.env.OVERALLOCATION_PERCENT) - : 40; if (!scope3ApiKey) { console.error('Error: SCOPE3_API_KEY environment variable is required'); @@ -19,7 +16,6 @@ const agent = new SimpleMediaAgent({ scope3ApiKey, scope3BaseUrl, minDailyBudget, - overallocationPercent, name: 'simple-media-agent', version: '1.0.0', }); @@ -33,6 +29,6 @@ console.error(` Simple Media Agent - Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} - Min Daily Budget: $${minDailyBudget} -- Overallocation: ${overallocationPercent}% +- Overallocation: Per media buy (40% default) - Protocol: MCP (stdio) `); diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts index 8be8bf4..3d0660b 100644 --- a/src/simple-media-agent.ts +++ b/src/simple-media-agent.ts @@ -1,29 +1,20 @@ import { FastMCP } from 'fastmcp'; import { z } from 'zod'; import { Scope3AgenticClient } from './sdk'; +import type { MediaBuyProduct } from './resources/media-buys'; interface SimpleMediaAgentMCPConfig { scope3ApiKey: string; scope3BaseUrl?: string; minDailyBudget?: number; - overallocationPercent?: number; name?: string; version?: string; } -interface Product { - id: string; - salesAgentId: string; - floorPrice?: number; - recommendedPrice?: number; - name?: string; -} - -interface MediaBuyAllocation { - productId: string; - salesAgentId: string; - budget: number; - cpm: number; +// MediaBuyAllocation extends MediaBuyProduct from the ADCP client library +// and adds overallocation percentage per media buy +interface MediaBuyAllocation extends MediaBuyProduct { + overallocationPercent: number; } /** @@ -36,14 +27,13 @@ export class SimpleMediaAgent { private config: Required< Omit & { name: string; version: string } >; - private activeTactics: Map; + private activeTactics: Map; constructor(config: SimpleMediaAgentMCPConfig) { this.config = { scope3ApiKey: config.scope3ApiKey, scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', minDailyBudget: config.minDailyBudget || 100, - overallocationPercent: config.overallocationPercent || 40, name: config.name || 'simple-media-agent', version: (config.version as `${number}.${number}.${number}`) || '1.0.0', }; @@ -118,7 +108,8 @@ export class SimpleMediaAgent { const salesAgents = (salesAgentsResponse.data as any[]) || []; // Call getProducts for each sales agent - const allProducts: Product[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allProducts: any[] = []; for (const agent of salesAgents) { try { const productsResponse = await this.scope3.products.discover({ @@ -126,7 +117,8 @@ export class SimpleMediaAgent { }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: any) => ({ id: p.id, salesAgentId: agent.id, floorPrice: p.floorPrice, @@ -153,7 +145,7 @@ export class SimpleMediaAgent { proposedTactics: [ { tacticId: `simple-passthrough-${campaignId}`, - execution: `Passthrough strategy with ${this.config.overallocationPercent}% overallocation: distribute budget across ${allProducts.length} products based on floor prices`, + execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices. Each media buy gets 40% overallocation.`, budgetCapacity: budgetRange?.max || 0, pricing: { method: 'passthrough', @@ -164,7 +156,7 @@ export class SimpleMediaAgent { metadata: { productCount: allProducts.length, avgFloorPrice, - overallocationPercent: this.config.overallocationPercent, + overallocationPerMediaBuy: 40, }, }, ], @@ -187,7 +179,8 @@ export class SimpleMediaAgent { const salesAgents = (salesAgentsResponse.data as any[]) || []; // Get products from all sales agents - const allProducts: Product[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allProducts: any[] = []; for (const agent of salesAgents) { try { const productsResponse = await this.scope3.products.discover({ @@ -195,7 +188,8 @@ export class SimpleMediaAgent { }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: Product) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const products = ((productsResponse.data as any[]) || []).map((p: any) => ({ id: p.id, salesAgentId: agent.id, floorPrice: p.floorPrice, @@ -218,32 +212,26 @@ export class SimpleMediaAgent { // Store tactic info for later reallocation this.activeTactics.set(tacticId, { - products: allProducts, allocations, }); // Create media buys for each allocation + // Each media buy has its own overallocation percentage const createdBuys = []; for (const allocation of allocations) { try { const result = await this.scope3.mediaBuys.create({ tacticId, - name: `Media Buy - ${allocation.productId}`, - products: [ - { - mediaProductId: allocation.productId, - salesAgentId: allocation.salesAgentId, - pricingCpm: allocation.cpm, - }, - ], + name: `Media Buy - ${allocation.mediaProductId}`, + products: [allocation], budget: { - amount: allocation.budget, - currency: 'USD', + amount: allocation.budgetAmount || 0, + currency: allocation.budgetCurrency || 'USD', }, }); createdBuys.push(result.data); } catch (error) { - console.error(`Error creating media buy for product ${allocation.productId}:`, error); + console.error(`Error creating media buy for product ${allocation.mediaProductId}:`, error); } } @@ -251,26 +239,24 @@ export class SimpleMediaAgent { acknowledged: true, mediaBuysCreated: createdBuys.length, allocations: allocations.map((a) => ({ - productId: a.productId, - budget: a.budget, - cpm: a.cpm, + productId: a.mediaProductId, + budget: a.budgetAmount, + cpm: a.pricingCpm, + overallocationPercent: a.overallocationPercent, })), }; } private calculateBudgetAllocation( - products: Product[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + products: any[], totalBudget: number ): MediaBuyAllocation[] { if (products.length === 0) return []; - // Apply overallocation to ensure delivery - const overallocationMultiplier = 1 + this.config.overallocationPercent / 100; - const allocatedBudget = totalBudget * overallocationMultiplier; - // Calculate N = number of products where daily budget >= minDailyBudget const assumedDays = 30; - const maxProducts = Math.floor(allocatedBudget / assumedDays / this.config.minDailyBudget); + const maxProducts = Math.floor(totalBudget / assumedDays / this.config.minDailyBudget); const n = Math.min(maxProducts, products.length); if (n === 0) return []; @@ -278,14 +264,18 @@ export class SimpleMediaAgent { // Take N cheapest products const selectedProducts = products.slice(0, n); - // Divide budget equally with overallocation applied - const budgetPerProduct = allocatedBudget / n; + // Divide budget equally + const budgetPerProduct = totalBudget / n; + // Each media buy gets its own overallocation percentage + // This allows per-buy customization and better delivery control return selectedProducts.map((product) => ({ - productId: product.id, + mediaProductId: product.id, salesAgentId: product.salesAgentId, - budget: budgetPerProduct, - cpm: product.floorPrice || 0, + budgetAmount: budgetPerProduct, + budgetCurrency: 'USD', + pricingCpm: product.floorPrice || 0, + overallocationPercent: 40, // Default 40% overallocation per media buy })); } From 9658e7081532837e50266ff0c99236ff7be9d972 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 21:03:14 -0400 Subject: [PATCH 09/11] Refactor tools into separate files and fix type issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all code review comments: 1. Overallocation is now sum of all media buy budgets (not per-buy) 2. Each tool in its own file for cleanliness 3. Remove 'any' types - use proper types from Scope3 client 4. Remove metadata from spec response File structure: - src/simple-media-agent/types.ts - Shared types - src/simple-media-agent/get-proposed-tactics.ts - Proposal logic - src/simple-media-agent/manage-tactic.ts - Budget allocation logic - src/simple-media-agent.ts - Main MCP server class Changes to overallocation: - Applied to TOTAL budget, not per media buy - allocatedTotalBudget = totalBudget * (1 + overallocationPercent/100) - Each media buy gets: allocatedTotalBudget / N - Sum of all media buy budgets = overallocated total Type improvements: - Check Array.isArray() for all responses - Explicitly type product arrays - No more 'any' types Spec compliance: - Removed metadata field from proposed tactics response ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/simple-media-agent-server.ts | 6 +- src/simple-media-agent.ts | 217 ++---------------- .../get-proposed-tactics.ts | 78 +++++++ src/simple-media-agent/manage-tactic.ts | 153 ++++++++++++ src/simple-media-agent/types.ts | 25 ++ 5 files changed, 275 insertions(+), 204 deletions(-) create mode 100644 src/simple-media-agent/get-proposed-tactics.ts create mode 100644 src/simple-media-agent/manage-tactic.ts create mode 100644 src/simple-media-agent/types.ts diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts index f168a8a..3c987e2 100644 --- a/src/simple-media-agent-server.ts +++ b/src/simple-media-agent-server.ts @@ -6,6 +6,9 @@ const scope3BaseUrl = process.env.SCOPE3_BASE_URL; const minDailyBudget = process.env.MIN_DAILY_BUDGET ? parseFloat(process.env.MIN_DAILY_BUDGET) : 100; +const overallocationPercent = process.env.OVERALLOCATION_PERCENT + ? parseFloat(process.env.OVERALLOCATION_PERCENT) + : 40; if (!scope3ApiKey) { console.error('Error: SCOPE3_API_KEY environment variable is required'); @@ -16,6 +19,7 @@ const agent = new SimpleMediaAgent({ scope3ApiKey, scope3BaseUrl, minDailyBudget, + overallocationPercent, name: 'simple-media-agent', version: '1.0.0', }); @@ -29,6 +33,6 @@ console.error(` Simple Media Agent - Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} - Min Daily Budget: $${minDailyBudget} -- Overallocation: Per media buy (40% default) +- Overallocation: ${overallocationPercent}% (sum of all media buy budgets) - Protocol: MCP (stdio) `); diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts index 3d0660b..4dc6811 100644 --- a/src/simple-media-agent.ts +++ b/src/simple-media-agent.ts @@ -1,21 +1,9 @@ import { FastMCP } from 'fastmcp'; import { z } from 'zod'; import { Scope3AgenticClient } from './sdk'; -import type { MediaBuyProduct } from './resources/media-buys'; - -interface SimpleMediaAgentMCPConfig { - scope3ApiKey: string; - scope3BaseUrl?: string; - minDailyBudget?: number; - name?: string; - version?: string; -} - -// MediaBuyAllocation extends MediaBuyProduct from the ADCP client library -// and adds overallocation percentage per media buy -interface MediaBuyAllocation extends MediaBuyProduct { - overallocationPercent: number; -} +import type { SimpleMediaAgentConfig, MediaBuyAllocation } from './simple-media-agent/types'; +import { getProposedTactics } from './simple-media-agent/get-proposed-tactics'; +import { manageTactic } from './simple-media-agent/manage-tactic'; /** * Simple Media Agent that exposes MCP tools @@ -25,15 +13,16 @@ export class SimpleMediaAgent { private server: FastMCP; private scope3: Scope3AgenticClient; private config: Required< - Omit & { name: string; version: string } + Omit & { name: string; version: string } >; private activeTactics: Map; - constructor(config: SimpleMediaAgentMCPConfig) { + constructor(config: SimpleMediaAgentConfig) { this.config = { scope3ApiKey: config.scope3ApiKey, scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', minDailyBudget: config.minDailyBudget || 100, + overallocationPercent: config.overallocationPercent || 40, name: config.name || 'simple-media-agent', version: (config.version as `${number}.${number}.${number}`) || '1.0.0', }; @@ -72,7 +61,7 @@ export class SimpleMediaAgent { seatId: z.string().describe('Seat/account ID'), }), execute: async (args) => { - const result = await this.handleGetProposedTactics(args); + const result = await getProposedTactics(this.scope3, args); return JSON.stringify(result, null, 2); }, }); @@ -89,196 +78,18 @@ export class SimpleMediaAgent { seatId: z.string().describe('Seat/account ID'), }), execute: async (args) => { - const result = await this.handleManageTactic(args); + const result = await manageTactic( + this.scope3, + this.config.minDailyBudget, + this.config.overallocationPercent, + this.activeTactics, + args + ); return JSON.stringify(result, null, 2); }, }); } - private async handleGetProposedTactics(args: { - campaignId: string; - budgetRange?: { min: number; max: number; currency: string }; - seatId: string; - }): Promise { - const { campaignId, budgetRange } = args; - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Call getProducts for each sales agent - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allProducts: any[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: any) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate average floor price for proposal - const avgFloorPrice = - allProducts.length > 0 - ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length - : 0; - - return { - proposedTactics: [ - { - tacticId: `simple-passthrough-${campaignId}`, - execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices. Each media buy gets 40% overallocation.`, - budgetCapacity: budgetRange?.max || 0, - pricing: { - method: 'passthrough', - estimatedCpm: avgFloorPrice, - currency: 'USD', - }, - sku: 'simple-passthrough', - metadata: { - productCount: allProducts.length, - avgFloorPrice, - overallocationPerMediaBuy: 40, - }, - }, - ], - }; - } - - private async handleManageTactic(args: { - tacticId: string; - tacticContext: { budget?: { amount: number } }; - brandAgentId: string; - seatId: string; - }): Promise { - const { tacticId, tacticContext } = args; - - console.log(`Managing tactic ${tacticId}`); - - // Get all registered sales agents - const salesAgentsResponse = await this.scope3.salesAgents.list(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const salesAgents = (salesAgentsResponse.data as any[]) || []; - - // Get products from all sales agents - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allProducts: any[] = []; - for (const agent of salesAgents) { - try { - const productsResponse = await this.scope3.products.discover({ - salesAgentId: agent.id, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const products = ((productsResponse.data as any[]) || []).map((p: any) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...products); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate budget allocation - const totalBudget = tacticContext.budget?.amount || 0; - const allocations = this.calculateBudgetAllocation(allProducts, totalBudget); - - // Store tactic info for later reallocation - this.activeTactics.set(tacticId, { - allocations, - }); - - // Create media buys for each allocation - // Each media buy has its own overallocation percentage - const createdBuys = []; - for (const allocation of allocations) { - try { - const result = await this.scope3.mediaBuys.create({ - tacticId, - name: `Media Buy - ${allocation.mediaProductId}`, - products: [allocation], - budget: { - amount: allocation.budgetAmount || 0, - currency: allocation.budgetCurrency || 'USD', - }, - }); - createdBuys.push(result.data); - } catch (error) { - console.error(`Error creating media buy for product ${allocation.mediaProductId}:`, error); - } - } - - return { - acknowledged: true, - mediaBuysCreated: createdBuys.length, - allocations: allocations.map((a) => ({ - productId: a.mediaProductId, - budget: a.budgetAmount, - cpm: a.pricingCpm, - overallocationPercent: a.overallocationPercent, - })), - }; - } - - private calculateBudgetAllocation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - products: any[], - totalBudget: number - ): MediaBuyAllocation[] { - if (products.length === 0) return []; - - // Calculate N = number of products where daily budget >= minDailyBudget - const assumedDays = 30; - const maxProducts = Math.floor(totalBudget / assumedDays / this.config.minDailyBudget); - const n = Math.min(maxProducts, products.length); - - if (n === 0) return []; - - // Take N cheapest products - const selectedProducts = products.slice(0, n); - - // Divide budget equally - const budgetPerProduct = totalBudget / n; - - // Each media buy gets its own overallocation percentage - // This allows per-buy customization and better delivery control - return selectedProducts.map((product) => ({ - mediaProductId: product.id, - salesAgentId: product.salesAgentId, - budgetAmount: budgetPerProduct, - budgetCurrency: 'USD', - pricingCpm: product.floorPrice || 0, - overallocationPercent: 40, // Default 40% overallocation per media buy - })); - } - async start(): Promise { await this.server.start({ transportType: 'stdio', diff --git a/src/simple-media-agent/get-proposed-tactics.ts b/src/simple-media-agent/get-proposed-tactics.ts new file mode 100644 index 0000000..0e1d690 --- /dev/null +++ b/src/simple-media-agent/get-proposed-tactics.ts @@ -0,0 +1,78 @@ +import type { Scope3AgenticClient } from '../sdk'; +import type { ProposedTactic } from './types'; + +export async function getProposedTactics( + scope3: Scope3AgenticClient, + args: { + campaignId: string; + budgetRange?: { min: number; max: number; currency: string }; + seatId: string; + } +): Promise<{ proposedTactics: ProposedTactic[] }> { + const { campaignId, budgetRange } = args; + + // Get all registered sales agents + const salesAgentsResponse = await scope3.salesAgents.list(); + const salesAgents = salesAgentsResponse.data || []; + + if (!Array.isArray(salesAgents)) { + throw new Error('Expected salesAgents to be an array'); + } + + // Call getProducts for each sales agent + const allProducts: Array<{ + id: string; + salesAgentId: string; + floorPrice?: number; + recommendedPrice?: number; + name?: string; + }> = []; + + for (const agent of salesAgents) { + try { + const productsResponse = await scope3.products.discover({ + salesAgentId: agent.id, + }); + + const products = productsResponse.data; + if (!Array.isArray(products)) continue; + + const mappedProducts = products.map((p) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...mappedProducts); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate average floor price for proposal + const avgFloorPrice = + allProducts.length > 0 + ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length + : 0; + + return { + proposedTactics: [ + { + tacticId: `simple-passthrough-${campaignId}`, + execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices with 40% overallocation.`, + budgetCapacity: budgetRange?.max || 0, + pricing: { + method: 'passthrough', + estimatedCpm: avgFloorPrice, + currency: 'USD', + }, + sku: 'simple-passthrough', + }, + ], + }; +} diff --git a/src/simple-media-agent/manage-tactic.ts b/src/simple-media-agent/manage-tactic.ts new file mode 100644 index 0000000..3cbce69 --- /dev/null +++ b/src/simple-media-agent/manage-tactic.ts @@ -0,0 +1,153 @@ +import type { Scope3AgenticClient } from '../sdk'; +import type { MediaBuyAllocation } from './types'; + +function calculateBudgetAllocation( + products: Array<{ + id: string; + salesAgentId: string; + floorPrice?: number; + }>, + totalBudget: number, + minDailyBudget: number, + overallocationPercent: number +): MediaBuyAllocation[] { + if (products.length === 0) return []; + + // Apply overallocation to total budget + // The SUM of all media buy budgets = totalBudget * (1 + overallocationPercent/100) + const overallocationMultiplier = 1 + overallocationPercent / 100; + const allocatedTotalBudget = totalBudget * overallocationMultiplier; + + // Calculate N = number of products where daily budget >= minDailyBudget + const assumedDays = 30; + const maxProducts = Math.floor(allocatedTotalBudget / assumedDays / minDailyBudget); + const n = Math.min(maxProducts, products.length); + + if (n === 0) return []; + + // Take N cheapest products + const selectedProducts = products.slice(0, n); + + // Divide overallocated budget equally + // Each media buy gets: allocatedTotalBudget / N + const budgetPerProduct = allocatedTotalBudget / n; + + return selectedProducts.map((product) => ({ + mediaProductId: product.id, + salesAgentId: product.salesAgentId, + budgetAmount: budgetPerProduct, + budgetCurrency: 'USD', + pricingCpm: product.floorPrice || 0, + })); +} + +export async function manageTactic( + scope3: Scope3AgenticClient, + minDailyBudget: number, + overallocationPercent: number, + tacticAllocations: Map, + args: { + tacticId: string; + tacticContext: { budget?: { amount: number } }; + brandAgentId: string; + seatId: string; + } +): Promise<{ + acknowledged: boolean; + mediaBuysCreated: number; + allocations: Array<{ + productId: string; + budget: number | undefined; + cpm: number | undefined; + }>; +}> { + const { tacticId, tacticContext } = args; + + console.log(`Managing tactic ${tacticId}`); + + // Get all registered sales agents + const salesAgentsResponse = await scope3.salesAgents.list(); + const salesAgents = salesAgentsResponse.data || []; + + if (!Array.isArray(salesAgents)) { + throw new Error('Expected salesAgents to be an array'); + } + + // Get products from all sales agents + const allProducts: Array<{ + id: string; + salesAgentId: string; + floorPrice?: number; + recommendedPrice?: number; + name?: string; + }> = []; + + for (const agent of salesAgents) { + try { + const productsResponse = await scope3.products.discover({ + salesAgentId: agent.id, + }); + + const products = productsResponse.data; + if (!Array.isArray(products)) continue; + + const mappedProducts = products.map((p) => ({ + id: p.id, + salesAgentId: agent.id, + floorPrice: p.floorPrice, + recommendedPrice: p.recommendedPrice, + name: p.name, + })); + + allProducts.push(...mappedProducts); + } catch (error) { + console.error(`Error fetching products from agent ${agent.id}:`, error); + } + } + + // Sort by floor price (cheapest first) + allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); + + // Calculate budget allocation with overallocation + const totalBudget = tacticContext.budget?.amount || 0; + const allocations = calculateBudgetAllocation( + allProducts, + totalBudget, + minDailyBudget, + overallocationPercent + ); + + // Store tactic info for later reallocation + tacticAllocations.set(tacticId, { + allocations, + }); + + // Create media buys for each allocation + const createdBuys = []; + for (const allocation of allocations) { + try { + const result = await scope3.mediaBuys.create({ + tacticId, + name: `Media Buy - ${allocation.mediaProductId}`, + products: [allocation], + budget: { + amount: allocation.budgetAmount || 0, + currency: allocation.budgetCurrency || 'USD', + }, + }); + createdBuys.push(result.data); + } catch (error) { + console.error(`Error creating media buy for product ${allocation.mediaProductId}:`, error); + } + } + + return { + acknowledged: true, + mediaBuysCreated: createdBuys.length, + allocations: allocations.map((a) => ({ + productId: a.mediaProductId || '', + budget: a.budgetAmount, + cpm: a.pricingCpm, + })), + }; +} diff --git a/src/simple-media-agent/types.ts b/src/simple-media-agent/types.ts new file mode 100644 index 0000000..e6b23ca --- /dev/null +++ b/src/simple-media-agent/types.ts @@ -0,0 +1,25 @@ +import type { MediaBuyProduct } from '../resources/media-buys'; + +export interface SimpleMediaAgentConfig { + scope3ApiKey: string; + scope3BaseUrl?: string; + minDailyBudget?: number; + overallocationPercent?: number; + name?: string; + version?: string; +} + +// MediaBuyAllocation uses MediaBuyProduct from the ADCP client library +export type MediaBuyAllocation = MediaBuyProduct; + +export interface ProposedTactic { + tacticId: string; + execution: string; + budgetCapacity: number; + pricing: { + method: string; + estimatedCpm: number; + currency: string; + }; + sku: string; +} From 50f5fa2f2587d71827368999693547a4f7c3382e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 20 Oct 2025 21:56:59 -0400 Subject: [PATCH 10/11] Add testing documentation and test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides ways to test the media agent: - Direct test script (test-media-agent.ts) - Claude Desktop integration instructions - MCP Inspector instructions - Debugging tips No live testing has been done yet - use these to verify the implementation works correctly. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- TESTING.md | 124 ++++++++++++++++++++++++++++++++++++++++++++ test-media-agent.ts | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 TESTING.md create mode 100644 test-media-agent.ts diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..4da0d65 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,124 @@ +# Testing Simple Media Agent + +## Prerequisites + +You need: +- Scope3 API key +- Registered sales agents in your Scope3 account +- Products available from those sales agents + +## Method 1: Direct Testing (Recommended First) + +Test the tools directly without MCP to verify logic: + +```bash +export SCOPE3_API_KEY=your_api_key +npx ts-node test-media-agent.ts +``` + +This will: +1. Call `get_proposed_tactics` with a test campaign +2. Call `manage_tactic` to create media buys +3. Show you the results and verify overallocation is working + +## Method 2: Claude Desktop Integration + +Add to your Claude Desktop MCP config (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "simple-media-agent": { + "command": "node", + "args": [ + "/path/to/agentic-client/.conductor/zurich-v4/dist/simple-media-agent-server.js" + ], + "env": { + "SCOPE3_API_KEY": "your_api_key_here", + "MIN_DAILY_BUDGET": "100", + "OVERALLOCATION_PERCENT": "40" + } + } + } +} +``` + +Then in Claude Desktop: + +``` +Use get_proposed_tactics to propose tactics for campaign "test-123" with max budget 50000 +``` + +Claude will call your MCP server and show the results! + +## Method 3: MCP Inspector + +Use the MCP Inspector tool to test your server: + +```bash +export SCOPE3_API_KEY=your_key +npx @modelcontextprotocol/inspector node dist/simple-media-agent-server.js +``` + +This opens a web UI where you can: +- See all available tools +- Call tools with test parameters +- View responses in real-time + +## What to Verify + +### get_proposed_tactics +โœ… Returns proposed tactics with: +- Correct tactic ID format +- Estimated CPM based on floor prices +- Budget capacity matches request +- No metadata field (spec compliance) + +### manage_tactic +โœ… Creates media buys with: +- Sum of all budgets = original budget * 1.4 (40% overallocation) +- Each media buy has correct product ID, sales agent ID, CPM +- Uses MediaBuyProduct structure from ADCP client +- N products where daily budget >= $100 + +## Example Output + +```json +{ + "proposedTactics": [ + { + "tacticId": "simple-passthrough-test-123", + "execution": "Passthrough strategy: distribute budget across 25 products...", + "budgetCapacity": 50000, + "pricing": { + "method": "passthrough", + "estimatedCpm": 2.50, + "currency": "USD" + }, + "sku": "simple-passthrough" + } + ] +} +``` + +## Debugging + +If something fails: + +1. **Check logs**: The server outputs to stderr +2. **Verify API key**: Make sure it's valid and has proper permissions +3. **Check sales agents**: Run `scope3.salesAgents.list()` to verify you have agents registered +4. **Check products**: Run `scope3.products.discover()` to verify products are available + +## Common Issues + +**"No products found"** +- You need to register sales agents first +- Sales agents need to have products available + +**"Budget calculation is wrong"** +- Check that overallocationPercent is set correctly +- Verify: sum of all media buy budgets = totalBudget * (1 + overallocationPercent/100) + +**"Type errors"** +- Make sure you've run `npm run build` after making changes diff --git a/test-media-agent.ts b/test-media-agent.ts new file mode 100644 index 0000000..8f9251a --- /dev/null +++ b/test-media-agent.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env ts-node +/** + * Manual test script for Simple Media Agent + * + * This tests the media agent by calling its tools directly (not via MCP) + * + * Usage: + * SCOPE3_API_KEY=your_key npx ts-node test-media-agent.ts + */ + +import { Scope3AgenticClient } from './src/sdk'; +import { getProposedTactics } from './src/simple-media-agent/get-proposed-tactics'; +import { manageTactic } from './src/simple-media-agent/manage-tactic'; + +async function testMediaAgent() { + const apiKey = process.env.SCOPE3_API_KEY; + + if (!apiKey) { + console.error('Error: SCOPE3_API_KEY environment variable is required'); + process.exit(1); + } + + const scope3 = new Scope3AgenticClient({ + apiKey, + baseUrl: 'https://api.agentic.scope3.com', + }); + + console.log('๐Ÿงช Testing Simple Media Agent\n'); + + // Test 1: Get Proposed Tactics + console.log('๐Ÿ“‹ Test 1: get_proposed_tactics'); + console.log('------------------------------------'); + try { + const proposals = await getProposedTactics(scope3, { + campaignId: 'test-campaign-123', + budgetRange: { + min: 10000, + max: 50000, + currency: 'USD', + }, + seatId: 'test-seat-123', + }); + + console.log('โœ… Success!'); + console.log('Proposed Tactics:', JSON.stringify(proposals, null, 2)); + console.log(''); + + // Test 2: Manage Tactic + if (proposals.proposedTactics.length > 0) { + console.log('๐Ÿ“‹ Test 2: manage_tactic'); + console.log('------------------------------------'); + + const tacticAllocations = new Map(); + + try { + const result = await manageTactic( + scope3, + 100, // minDailyBudget + 40, // overallocationPercent + tacticAllocations, + { + tacticId: proposals.proposedTactics[0].tacticId, + tacticContext: { + budget: { + amount: 25000, + }, + }, + brandAgentId: 'test-brand-agent-123', + seatId: 'test-seat-123', + } + ); + + console.log('โœ… Success!'); + console.log('Result:', JSON.stringify(result, null, 2)); + console.log(''); + + console.log('๐Ÿ“Š Summary:'); + console.log(`- Media buys created: ${result.mediaBuysCreated}`); + console.log( + `- Total budget allocated: $${result.allocations.reduce((sum, a) => sum + (a.budget || 0), 0).toFixed(2)}` + ); + console.log(`- Original budget: $25,000`); + console.log(`- Overallocation: 40%`); + console.log(`- Expected total: $35,000`); + } catch (error) { + console.error('โŒ Error:', error); + } + } + } catch (error) { + console.error('โŒ Error:', error); + } +} + +testMediaAgent().catch(console.error); From b83ccbad9cfabfe3b9ad5ec4d2896cfdfe54fa95 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 31 Oct 2025 04:44:53 -0400 Subject: [PATCH 11/11] Align media agent with merged OpenAPI spec and improve client cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync schemas from docs.agentic.scope3.com (media-agent-openapi.yaml now published) - Fix budget structure: tacticContext.budget is number, not nested object - Fix ManageTacticResponse: return only acknowledged + optional reason per spec - Improve Scope3Client disconnect: properly close MCP client and HTTP transport - Add npm run update-schemas script for future schema syncing - Update test script to use correct budget structure The simple media agent implementation now matches the merged PR #136 spec exactly. Ready for real-world testing against Scope3 platform. ๐Ÿค– Generated with Claude Code Co-Authored-By: Claude --- openapi.yaml | 203 ++++++------ package.json | 2 +- src/client.ts | 3 + .../get-proposed-tactics.ts | 12 +- src/simple-media-agent/manage-tactic.ts | 26 +- src/types/api.ts | 89 +++--- src/types/media-agent-api.ts | 295 +++++++----------- test-media-agent.ts | 37 ++- 8 files changed, 301 insertions(+), 366 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 1c3b72d..b56f3ab 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4669,21 +4669,6 @@ components: example: https://example.com/brand-manifest type: string format: uri - channels: - description: Advertising channels to enable - example: - - app - - display - - ctv - type: array - items: - type: string - enum: - - ctv - - video - - display - - app - - social countryCodes: description: Country codes (ISO 3166-1 alpha-2) example: @@ -4695,17 +4680,6 @@ components: type: string minLength: 2 maxLength: 2 - languages: - description: Language codes (ISO 639-1) - example: - - en - - es - - fr - type: array - items: - type: string - minLength: 2 - maxLength: 2 required: - name UpdateBrandAgentInput: @@ -4732,17 +4706,6 @@ components: example: https://example.com/brand-manifest type: string format: uri - channels: - description: Updated channels - type: array - items: - type: string - enum: - - ctv - - video - - display - - app - - social countryCodes: description: Updated country codes type: array @@ -4750,13 +4713,6 @@ components: type: string minLength: 2 maxLength: 2 - languages: - description: Updated language codes - type: array - items: - type: string - minLength: 2 - maxLength: 2 required: - brandAgentId DeleteBrandAgentInput: @@ -4866,63 +4822,38 @@ components: type: string minLength: 1 maxLength: 255 - description: - description: Story description - type: string prompt: description: Brand story prompt type: string - isPrimary: - description: Whether this is the primary story - type: boolean - channelCodes: - description: Channel codes - type: array - items: - type: string - enum: - - ctv - - video - - display - - app - - social - countryCodes: - description: Country codes - type: array - items: - type: string brands: description: Brand names type: array items: type: string languages: - description: Language codes + description: Language codes (use language_list tool to see available options) type: array items: type: string required: - brandAgentId - name - - isPrimary + - languages UpdateBrandStoryInput: type: object properties: + brandStoryId: + description: Brand story ID + type: string name: description: Story name type: string - previousModelId: - description: Previous model ID (bigint or string) - anyOf: - - type: integer - format: int64 - - type: string prompt: description: Updated brand story prompt type: string minLength: 1 required: - - previousModelId + - brandStoryId - prompt DeleteBrandStoryInput: type: object @@ -5176,11 +5107,6 @@ components: - PAUSED - ARCHIVED - DRAFT - expectedVersion: - description: Expected version for optimistic locking - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 required: - campaignId DeleteCampaignInput: @@ -5192,6 +5118,9 @@ components: example: cmp_987654321 type: string minLength: 1 + hardDelete: + description: 'If true, permanently delete the campaign. Default: false (soft delete/archive)' + type: boolean required: - campaignId GetCampaignSummaryInput: @@ -5306,10 +5235,6 @@ components: campaignId: description: Optional campaign ID (object ID) to assign creative to type: string - version: - type: string - registeredBy: - type: string required: - brandAgentId - name @@ -5320,12 +5245,8 @@ components: type: string name: type: string - description: + status: type: string - assetIds: - type: array - items: - type: string required: - creativeId DeleteCreativeInput: @@ -5500,8 +5421,11 @@ components: properties: mediaBuyId: type: string + confirm: + type: boolean required: - mediaBuyId + - confirm ExecuteMediaBuyInput: type: object properties: @@ -5554,16 +5478,24 @@ components: ListNotificationsInput: type: object properties: - read: + brandAgentId: + type: number + campaignId: + type: string + creativeId: + type: string + tacticId: + type: string + types: + type: array + items: + type: string + unreadOnly: type: boolean - take: - type: integer - exclusiveMinimum: true - maximum: 9007199254740991 - skip: - type: integer - minimum: 0 - maximum: 9007199254740991 + limit: + type: number + offset: + type: number MarkNotificationReadInput: type: object properties: @@ -5776,8 +5708,25 @@ components: type: string name: type: string - organization: + description: + type: string + endpointUrl: type: string + format: uri + protocol: + type: string + enum: + - MCP + - A2A + authenticationType: + type: string + enum: + - API_KEY + - OAUTH + - NO_AUTH + authConfig: + type: object + additionalProperties: {} required: - agentId ListSalesAgentAccountsInput: @@ -5842,6 +5791,35 @@ components: type: array items: type: string + languages: + description: Language codes + type: array + items: + type: string + availableBrandStandards: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + required: + - id + - name + availableBrandStory: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + required: + - id + - name required: - campaignId - name @@ -5868,6 +5846,30 @@ components: type: array items: type: string + availableBrandStandards: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + required: + - id + - name + availableBrandStory: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + required: + - id + - name required: - tacticId DeleteTacticInput: @@ -5875,8 +5877,11 @@ components: properties: tacticId: type: number + confirm: + type: boolean required: - tacticId + - confirm GetTacticInput: type: object properties: diff --git a/package.json b/package.json index 8dd5637..ebf70b2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "type-check": "tsc --noEmit", "generate-types": "openapi-typescript openapi.yaml -o src/types/api.ts", "generate-media-agent-types": "openapi-typescript media-agent-openapi.yaml -o src/types/media-agent-api.ts", - "update-schemas": "curl -f -o openapi.yaml https://docs.agentic.scope3.com/openapi.yaml && npm run generate-types", + "update-schemas": "curl -f -o openapi.yaml https://docs.agentic.scope3.com/openapi.yaml && curl -f -o media-agent-openapi.yaml https://docs.agentic.scope3.com/media-agent-openapi.yaml && npm run generate-types && npm run generate-media-agent-types", "prepare": "husky", "pretest": "npm run type-check", "changeset": "changeset", diff --git a/src/client.ts b/src/client.ts index 1b864e1..36ca043 100644 --- a/src/client.ts +++ b/src/client.ts @@ -55,6 +55,9 @@ export class Scope3Client { } await this.mcpClient.close(); + if (this.transport) { + await this.transport.close(); + } this.connected = false; } diff --git a/src/simple-media-agent/get-proposed-tactics.ts b/src/simple-media-agent/get-proposed-tactics.ts index 0e1d690..055bdad 100644 --- a/src/simple-media-agent/get-proposed-tactics.ts +++ b/src/simple-media-agent/get-proposed-tactics.ts @@ -51,14 +51,20 @@ export async function getProposedTactics( } } + // Fail if no products found + if (allProducts.length === 0) { + throw new Error( + `No products available from ${salesAgents.length} sales agents. ` + + 'Cannot propose tactics without available inventory.' + ); + } + // Sort by floor price (cheapest first) allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); // Calculate average floor price for proposal const avgFloorPrice = - allProducts.length > 0 - ? allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length - : 0; + allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length; return { proposedTactics: [ diff --git a/src/simple-media-agent/manage-tactic.ts b/src/simple-media-agent/manage-tactic.ts index 3cbce69..ab6078a 100644 --- a/src/simple-media-agent/manage-tactic.ts +++ b/src/simple-media-agent/manage-tactic.ts @@ -48,18 +48,13 @@ export async function manageTactic( tacticAllocations: Map, args: { tacticId: string; - tacticContext: { budget?: { amount: number } }; + tacticContext: { budget?: number }; brandAgentId: string; seatId: string; } ): Promise<{ acknowledged: boolean; - mediaBuysCreated: number; - allocations: Array<{ - productId: string; - budget: number | undefined; - cpm: number | undefined; - }>; + reason?: string; }> { const { tacticId, tacticContext } = args; @@ -109,7 +104,7 @@ export async function manageTactic( allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); // Calculate budget allocation with overallocation - const totalBudget = tacticContext.budget?.amount || 0; + const totalBudget = tacticContext.budget || 0; const allocations = calculateBudgetAllocation( allProducts, totalBudget, @@ -123,10 +118,9 @@ export async function manageTactic( }); // Create media buys for each allocation - const createdBuys = []; for (const allocation of allocations) { try { - const result = await scope3.mediaBuys.create({ + await scope3.mediaBuys.create({ tacticId, name: `Media Buy - ${allocation.mediaProductId}`, products: [allocation], @@ -135,19 +129,17 @@ export async function manageTactic( currency: allocation.budgetCurrency || 'USD', }, }); - createdBuys.push(result.data); } catch (error) { console.error(`Error creating media buy for product ${allocation.mediaProductId}:`, error); + // Return failure if we can't create media buys + return { + acknowledged: false, + reason: `Failed to create media buy for product ${allocation.mediaProductId}`, + }; } } return { acknowledged: true, - mediaBuysCreated: createdBuys.length, - allocations: allocations.map((a) => ({ - productId: a.mediaProductId || '', - budget: a.budgetAmount, - cpm: a.pricingCpm, - })), }; } diff --git a/src/types/api.ts b/src/types/api.ts index ff4517f..b7f5113 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -511,15 +511,6 @@ export interface components { * @example https://example.com/brand-manifest */ manifestUrl?: string; - /** - * @description Advertising channels to enable - * @example [ - * "app", - * "display", - * "ctv" - * ] - */ - channels?: ('ctv' | 'video' | 'display' | 'app' | 'social')[]; /** * @description Country codes (ISO 3166-1 alpha-2) * @example [ @@ -529,15 +520,6 @@ export interface components { * ] */ countryCodes?: string[]; - /** - * @description Language codes (ISO 639-1) - * @example [ - * "en", - * "es", - * "fr" - * ] - */ - languages?: string[]; }; /** @description Parameters for updating a brand agent */ UpdateBrandAgentInput: { @@ -556,12 +538,8 @@ export interface components { * @example https://example.com/brand-manifest */ manifestUrl?: string; - /** @description Updated channels */ - channels?: ('ctv' | 'video' | 'display' | 'app' | 'social')[]; /** @description Updated country codes */ countryCodes?: string[]; - /** @description Updated language codes */ - languages?: string[]; }; /** @description Parameters for deleting a brand agent */ DeleteBrandAgentInput: { @@ -614,26 +592,18 @@ export interface components { brandAgentId: number | string; /** @description Story name */ name: string; - /** @description Story description */ - description?: string; /** @description Brand story prompt */ prompt?: string; - /** @description Whether this is the primary story */ - isPrimary: boolean; - /** @description Channel codes */ - channelCodes?: ('ctv' | 'video' | 'display' | 'app' | 'social')[]; - /** @description Country codes */ - countryCodes?: string[]; /** @description Brand names */ brands?: string[]; - /** @description Language codes */ - languages?: string[]; + /** @description Language codes (use language_list tool to see available options) */ + languages: string[]; }; UpdateBrandStoryInput: { + /** @description Brand story ID */ + brandStoryId: string; /** @description Story name */ name?: string; - /** @description Previous model ID (bigint or string) */ - previousModelId: number | string; /** @description Updated brand story prompt */ prompt: string; }; @@ -786,8 +756,6 @@ export interface components { * @enum {string} */ status?: 'ACTIVE' | 'PAUSED' | 'ARCHIVED' | 'DRAFT'; - /** @description Expected version for optimistic locking */ - expectedVersion?: number; }; /** @description Parameters for deleting a campaign */ DeleteCampaignInput: { @@ -796,6 +764,8 @@ export interface components { * @example cmp_987654321 */ campaignId: string; + /** @description If true, permanently delete the campaign. Default: false (soft delete/archive) */ + hardDelete?: boolean; }; GetCampaignSummaryInput: { /** @description Campaign ID */ @@ -849,14 +819,11 @@ export interface components { assemblyMethod?: 'CREATIVE_AGENT' | 'ACTIVATION' | 'PUBLISHER'; /** @description Optional campaign ID (object ID) to assign creative to */ campaignId?: string; - version?: string; - registeredBy?: string; }; UpdateCreativeInput: { creativeId: string; name?: string; - description?: string; - assetIds?: string[]; + status?: string; }; DeleteCreativeInput: { creativeId: string; @@ -923,6 +890,7 @@ export interface components { }; DeleteMediaBuyInput: { mediaBuyId: string; + confirm: boolean; }; ExecuteMediaBuyInput: { mediaBuyId: string; @@ -949,9 +917,14 @@ export interface components { newBudgetAmount: number; }; ListNotificationsInput: { - read?: boolean; - take?: number; - skip?: number; + brandAgentId?: number; + campaignId?: string; + creativeId?: string; + tacticId?: string; + types?: string[]; + unreadOnly?: boolean; + limit?: number; + offset?: number; }; MarkNotificationReadInput: { notificationId: string; @@ -1031,7 +1004,16 @@ export interface components { UpdateSalesAgentInput: { agentId: string; name?: string; - organization?: string; + description?: string; + /** Format: uri */ + endpointUrl?: string; + /** @enum {string} */ + protocol?: 'MCP' | 'A2A'; + /** @enum {string} */ + authenticationType?: 'API_KEY' | 'OAUTH' | 'NO_AUTH'; + authConfig?: { + [key: string]: unknown; + }; }; ListSalesAgentAccountsInput: { agentId: string; @@ -1058,6 +1040,16 @@ export interface components { prompt?: string; channelCodes?: ('ctv' | 'video' | 'display' | 'app' | 'social')[]; countryCodes?: string[]; + /** @description Language codes */ + languages?: string[]; + availableBrandStandards?: { + id: number; + name: string; + }[]; + availableBrandStory?: { + id: number; + name: string; + }[]; }; UpdateTacticInput: { tacticId: number; @@ -1065,9 +1057,18 @@ export interface components { prompt?: string; channelCodes?: ('ctv' | 'video' | 'display' | 'app' | 'social')[]; countryCodes?: string[]; + availableBrandStandards?: { + id: number; + name: string; + }[]; + availableBrandStory?: { + id: number; + name: string; + }[]; }; DeleteTacticInput: { tacticId: number; + confirm: boolean; }; GetTacticInput: { tacticId: number; diff --git a/src/types/media-agent-api.ts b/src/types/media-agent-api.ts index c7c02cf..384a426 100644 --- a/src/types/media-agent-api.ts +++ b/src/types/media-agent-api.ts @@ -5,126 +5,63 @@ export interface paths { '/get-proposed-tactics': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** * Get tactic proposals from your agent * @description Scope3 calls this endpoint when setting up a campaign to ask what tactics - * your agent can handle and how you would approach execution. + * your agent can handle and how you would approach execution. * - * Analyze the campaign and respond with proposed tactics, budget capacity, - * and your pricing model. + * Analyze the campaign and respond with proposed tactics, budget capacity, + * and your pricing model. */ post: operations['get_proposed_tactics']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; }; '/manage-tactic': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** * Accept or decline tactic assignment * @description Scope3 calls this when your agent is assigned to manage a tactic. - * You should acknowledge and begin setup, or decline if you can't fulfill it. + * You should acknowledge and begin setup, or decline if you can't fulfill it. * - * The tactic context contains everything you need: budget, schedule, - * targeting constraints, creatives, and any custom fields. + * The tactic context contains everything you need: budget, schedule, + * targeting constraints, creatives, and any custom fields. */ post: operations['manage_tactic']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; }; '/tactic-context-updated': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** * Notification of tactic changes * @description Scope3 calls this when a tactic is modified by the user or their agent. - * Changes may include budget adjustments, schedule changes, or other updates. + * Changes may include budget adjustments, schedule changes, or other updates. * - * Your agent MUST handle these changes as they may impact targeting, - * delivery, or budget allocation. + * Your agent MUST handle these changes as they may impact targeting, + * delivery, or budget allocation. */ post: operations['tactic_context_updated']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; }; '/tactic-creatives-updated': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** * Notification of creative changes * @description Scope3 calls this when creatives are added, removed, or modified for a tactic. * - * Update your media buys to use the new creative assets. + * Update your media buys to use the new creative assets. */ post: operations['tactic_creatives_updated']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; }; '/tactic-feedback': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** * Performance feedback from orchestrator * @description Scope3 sends performance feedback to help you optimize delivery. * - * - deliveryIndex: 100 = on target, <100 = under-delivering, >100 = over-delivering - * - performanceIndex: 100 = maximum, relative to target or other tactics + * - deliveryIndex: 100 = on target, <100 = under-delivering, >100 = over-delivering + * - performanceIndex: 100 = maximum, relative to target or other tactics * - * Your agent MAY use this to adjust targeting, budget allocation, or other settings. + * Your agent MAY use this to adjust targeting, budget allocation, or other settings. */ post: operations['tactic_feedback']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; }; } + export type webhooks = Record; + export interface components { schemas: { /** @description Budget range for campaign planning (buyer typically won't reveal full budget) */ @@ -144,7 +81,7 @@ export interface components { * @default USD * @example USD */ - currency: string; + currency?: string; }; TacticPricing: { /** @@ -163,7 +100,7 @@ export interface components { * @default USD * @example USD */ - currency: string; + currency?: string; }; CustomField: { /** @@ -205,25 +142,25 @@ export interface components { /** * @description Advertising channels (aligned with AdCP channel schema) * @example [ - * "display", - * "video" - * ] + * "display", + * "video" + * ] */ channels?: ('display' | 'video' | 'audio' | 'native' | 'ctv')[]; /** * @description ISO 3166-1 alpha-2 country codes * @example [ - * "US", - * "CA" - * ] + * "US", + * "CA" + * ] */ countries?: string[]; /** * @description Campaign objectives/outcomes (e.g., awareness, consideration, conversion) * @example [ - * "awareness", - * "consideration" - * ] + * "awareness", + * "consideration" + * ] */ objectives?: string[]; /** @@ -234,10 +171,10 @@ export interface components { /** * @description AdCP pricing models acceptable to the buyer for sales agent pricing * @example [ - * "cpm", - * "vcpm", - * "flat_rate" - * ] + * "cpm", + * "vcpm", + * "flat_rate" + * ] */ acceptedPricingMethods?: ('cpm' | 'vcpm' | 'cpc' | 'cpcv' | 'cpv' | 'cpp' | 'flat_rate')[]; promotedOfferings?: components['schemas']['PromotedOfferings']; @@ -287,7 +224,7 @@ export interface components { * @default USD * @example USD */ - budgetCurrency: string; + budgetCurrency?: string; /** * Format: date-time * @description Tactic start date in UTC (ISO 8601 format) @@ -309,8 +246,8 @@ export interface components { /** * @description Target countries * @example [ - * "US" - * ] + * "US" + * ] */ countries?: string[]; /** @description Creative assets to use (uses Creative from main schema) */ @@ -338,8 +275,8 @@ export interface components { /** * @description Custom fields provided by advertiser * @example { - * "targetVCPM": 2.5 - * } + * "targetVCPM": 2.5 + * } */ customFields?: Record; }; @@ -445,25 +382,25 @@ export interface components { /** * @description ISO 3166-1 alpha-2 country codes this standard applies to * @example [ - * "US", - * "CA" - * ] + * "US", + * "CA" + * ] */ countryCodes: string[]; /** * @description Channels this standard applies to * @example [ - * "display", - * "video" - * ] + * "display", + * "video" + * ] */ channelCodes: string[]; /** * @description Brand names this standard applies to * @example [ - * "Brand A", - * "Brand B" - * ] + * "Brand A", + * "Brand B" + * ] */ brands: string[]; /** @@ -517,9 +454,9 @@ export interface components { /** * @description Specific product IDs to promote * @example [ - * "prod_123", - * "prod_456" - * ] + * "prod_123", + * "prod_456" + * ] */ product_ids?: string[]; }; @@ -563,9 +500,9 @@ export interface components { /** * @description Semantic tags (e.g., 'dark', 'light', 'square', 'horizontal', 'icon') * @example [ - * "dark", - * "horizontal" - * ] + * "dark", + * "horizontal" + * ] */ tags?: string[]; /** @@ -639,10 +576,10 @@ export interface components { /** * @description Product categories available in the catalog * @example [ - * "electronics", - * "apparel", - * "home_goods" - * ] + * "electronics", + * "apparel", + * "home_goods" + * ] */ categories?: string[]; /** @@ -693,15 +630,21 @@ export interface components { headers: never; pathItems: never; } + export type $defs = Record; + +export type external = Record; + export interface operations { + /** + * Get tactic proposals from your agent + * @description Scope3 calls this endpoint when setting up a campaign to ask what tactics + * your agent can handle and how you would approach execution. + * + * Analyze the campaign and respond with proposed tactics, budget capacity, + * and your pricing model. + */ get_proposed_tactics: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { 'application/json': components['schemas']['GetProposedTacticsRequest']; @@ -710,36 +653,29 @@ export interface operations { responses: { /** @description Tactic proposals from your agent */ 200: { - headers: { - [name: string]: unknown; - }; content: { 'application/json': components['schemas']['GetProposedTacticsResponse']; }; }; /** @description Invalid request */ 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; /** @description Internal error */ 500: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; }; }; + /** + * Accept or decline tactic assignment + * @description Scope3 calls this when your agent is assigned to manage a tactic. + * You should acknowledge and begin setup, or decline if you can't fulfill it. + * + * The tactic context contains everything you need: budget, schedule, + * targeting constraints, creatives, and any custom fields. + */ manage_tactic: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { 'application/json': components['schemas']['ManageTacticRequest']; @@ -748,29 +684,25 @@ export interface operations { responses: { /** @description Acknowledgment of tactic assignment */ 200: { - headers: { - [name: string]: unknown; - }; content: { 'application/json': components['schemas']['ManageTacticResponse']; }; }; /** @description Invalid request */ 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; }; }; + /** + * Notification of tactic changes + * @description Scope3 calls this when a tactic is modified by the user or their agent. + * Changes may include budget adjustments, schedule changes, or other updates. + * + * Your agent MUST handle these changes as they may impact targeting, + * delivery, or budget allocation. + */ tactic_context_updated: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { 'application/json': components['schemas']['TacticContextUpdatedRequest']; @@ -779,27 +711,21 @@ export interface operations { responses: { /** @description Acknowledged */ 200: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; /** @description Invalid request */ 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; }; }; + /** + * Notification of creative changes + * @description Scope3 calls this when creatives are added, removed, or modified for a tactic. + * + * Update your media buys to use the new creative assets. + */ tactic_creatives_updated: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { 'application/json': components['schemas']['TacticCreativesUpdatedRequest']; @@ -808,27 +734,24 @@ export interface operations { responses: { /** @description Acknowledged */ 200: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; /** @description Invalid request */ 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; }; }; + /** + * Performance feedback from orchestrator + * @description Scope3 sends performance feedback to help you optimize delivery. + * + * - deliveryIndex: 100 = on target, <100 = under-delivering, >100 = over-delivering + * - performanceIndex: 100 = maximum, relative to target or other tactics + * + * Your agent MAY use this to adjust targeting, budget allocation, or other settings. + */ tactic_feedback: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { 'application/json': components['schemas']['TacticFeedbackRequest']; @@ -837,17 +760,11 @@ export interface operations { responses: { /** @description Acknowledged */ 200: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; /** @description Invalid request */ 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + content: never; }; }; }; diff --git a/test-media-agent.ts b/test-media-agent.ts index 8f9251a..876c8e6 100644 --- a/test-media-agent.ts +++ b/test-media-agent.ts @@ -61,9 +61,7 @@ async function testMediaAgent() { { tacticId: proposals.proposedTactics[0].tacticId, tacticContext: { - budget: { - amount: 25000, - }, + budget: 25000, // budget is a number, not an object }, brandAgentId: 'test-brand-agent-123', seatId: 'test-seat-123', @@ -72,23 +70,36 @@ async function testMediaAgent() { console.log('โœ… Success!'); console.log('Result:', JSON.stringify(result, null, 2)); - console.log(''); - console.log('๐Ÿ“Š Summary:'); - console.log(`- Media buys created: ${result.mediaBuysCreated}`); - console.log( - `- Total budget allocated: $${result.allocations.reduce((sum, a) => sum + (a.budget || 0), 0).toFixed(2)}` - ); - console.log(`- Original budget: $25,000`); - console.log(`- Overallocation: 40%`); - console.log(`- Expected total: $35,000`); + if (result.acknowledged) { + console.log(''); + console.log('๐Ÿ“Š Summary:'); + console.log(`- Tactic acknowledged: ${result.acknowledged}`); + console.log(`- Original budget: $25,000`); + console.log(`- Overallocation: 40%`); + console.log(`- Expected total allocated: $35,000`); + } else { + console.log(`- Reason: ${result.reason}`); + } } catch (error) { console.error('โŒ Error:', error); } } } catch (error) { console.error('โŒ Error:', error); + await scope3.disconnect(); + process.exit(1); } + + await scope3.disconnect(); } -testMediaAgent().catch(console.error); +testMediaAgent() + .then(() => { + console.log('\nโœ… All tests completed'); + process.exit(0); + }) + .catch((error) => { + console.error('\nโŒ Test failed:', error); + process.exit(1); + });