From d6aa8fca8d4fd08c04b6ec5d79c6f5418f038050 Mon Sep 17 00:00:00 2001 From: "scope3-wizard[bot]" <1852797+scope3-wizard[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:01:06 +0000 Subject: [PATCH] Sync from scope3data/agentic-api@7146ff56688cd67bdb7c0f0c9c842a8daadfe3a1 (source: scope3data/agentic-api#15783) --- .agents/plugins/marketplace.json | 140 +++++++++++++ .claude-plugin/marketplace.json | 23 +++ README.md | 41 +++- SOURCE_DIGEST | 2 +- examples/python/README.md | 21 ++ examples/python/main.py | 70 +++++++ examples/python/requirements.txt | 2 + examples/python/test_main.py | 194 ++++++++++++++++++ examples/typescript/README.md | 19 ++ examples/typescript/package.json | 20 ++ examples/typescript/src/main.test.ts | 165 +++++++++++++++ examples/typescript/src/main.ts | 129 ++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 35 ++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 36 ++++ .../amc-listing/.claude-plugin/plugin.json | 2 +- plugins/amc-listing/.codex-plugin/plugin.json | 34 +++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 35 ++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 37 ++++ .../.claude-plugin/plugin.json | 26 +++ .../.codex-plugin/plugin.json | 43 ++++ plugins/apostra-developer/.mcp.json | 8 + .../assets/apostra-icon-purple.svg | 3 + .../assets/composer-icon.png | Bin 0 -> 986 bytes .../assets/directory-icon.png | Bin 0 -> 8615 bytes .../skills/build-with-apostra/SKILL.md | 93 +++++++++ .../build-with-apostra/agents/openai.yaml | 12 ++ .../skills/build-with-apostra/manifest.json | 119 +++++++++++ .../build-with-apostra/references/agents.md | 93 +++++++++ .../references/connection.md | 65 ++++++ .../references/data-pipelines.md | 82 ++++++++ .../references/sales-agent-testing.md | 43 ++++ .../references/scenarios.json | 77 +++++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 37 ++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 37 ++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 37 ++++ .../.claude-plugin/plugin.json | 2 +- .../buyer-reporting/.codex-plugin/plugin.json | 37 ++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 37 ++++ 46 files changed, 1853 insertions(+), 19 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 examples/python/README.md create mode 100644 examples/python/main.py create mode 100644 examples/python/requirements.txt create mode 100644 examples/python/test_main.py create mode 100644 examples/typescript/README.md create mode 100644 examples/typescript/package.json create mode 100644 examples/typescript/src/main.test.ts create mode 100644 examples/typescript/src/main.ts create mode 100644 plugins/amc-campaign-management/.codex-plugin/plugin.json create mode 100644 plugins/amc-distribution/.codex-plugin/plugin.json create mode 100644 plugins/amc-listing/.codex-plugin/plugin.json create mode 100644 plugins/amc-merchandising/.codex-plugin/plugin.json create mode 100644 plugins/amc-self-serve-buyer/.codex-plugin/plugin.json create mode 100644 plugins/apostra-developer/.claude-plugin/plugin.json create mode 100644 plugins/apostra-developer/.codex-plugin/plugin.json create mode 100644 plugins/apostra-developer/.mcp.json create mode 100644 plugins/apostra-developer/assets/apostra-icon-purple.svg create mode 100644 plugins/apostra-developer/assets/composer-icon.png create mode 100644 plugins/apostra-developer/assets/directory-icon.png create mode 100644 plugins/apostra-developer/skills/build-with-apostra/SKILL.md create mode 100644 plugins/apostra-developer/skills/build-with-apostra/agents/openai.yaml create mode 100644 plugins/apostra-developer/skills/build-with-apostra/manifest.json create mode 100644 plugins/apostra-developer/skills/build-with-apostra/references/agents.md create mode 100644 plugins/apostra-developer/skills/build-with-apostra/references/connection.md create mode 100644 plugins/apostra-developer/skills/build-with-apostra/references/data-pipelines.md create mode 100644 plugins/apostra-developer/skills/build-with-apostra/references/sales-agent-testing.md create mode 100644 plugins/apostra-developer/skills/build-with-apostra/references/scenarios.json create mode 100644 plugins/buyer-account-setup/.codex-plugin/plugin.json create mode 100644 plugins/buyer-campaign-management/.codex-plugin/plugin.json create mode 100644 plugins/buyer-creative-management/.codex-plugin/plugin.json create mode 100644 plugins/buyer-reporting/.codex-plugin/plugin.json create mode 100644 plugins/sales-agent-testing/.codex-plugin/plugin.json diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..6ef10e9 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,140 @@ +{ + "name": "interchange-plugin", + "interface": { + "displayName": "Apostra" + }, + "plugins": [ + { + "name": "apostra-developer", + "source": { + "source": "local", + "path": "./plugins/apostra-developer" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Development" + }, + { + "name": "amc-listing", + "source": { + "source": "local", + "path": "./plugins/amc-listing" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "amc-merchandising", + "source": { + "source": "local", + "path": "./plugins/amc-merchandising" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "amc-distribution", + "source": { + "source": "local", + "path": "./plugins/amc-distribution" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Deployment" + }, + { + "name": "amc-campaign-management", + "source": { + "source": "local", + "path": "./plugins/amc-campaign-management" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "buyer-account-setup", + "source": { + "source": "local", + "path": "./plugins/buyer-account-setup" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "buyer-campaign-management", + "source": { + "source": "local", + "path": "./plugins/buyer-campaign-management" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "buyer-creative-management", + "source": { + "source": "local", + "path": "./plugins/buyer-creative-management" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "buyer-reporting", + "source": { + "source": "local", + "path": "./plugins/buyer-reporting" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Monitoring" + }, + { + "name": "sales-agent-testing", + "source": { + "source": "local", + "path": "./plugins/sales-agent-testing" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Testing" + }, + { + "name": "amc-self-serve-buyer", + "source": { + "source": "local", + "path": "./plugins/amc-self-serve-buyer" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 14b88b4..c711b66 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,6 +7,29 @@ "email": "engineering@scope3.com" }, "plugins": [ + { + "name": "apostra-developer", + "description": "Plan, run, automate, and report on advertising across sellers, or build those jobs into an application.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "source": "./plugins/apostra-developer", + "category": "development", + "keywords": [ + "apostra", + "advertising", + "campaign automation", + "growth marketing", + "media buying", + "reporting", + "developer", + "mcp", + "agents" + ], + "homepage": "https://docs.interchange.io/v3/overview", + "license": "Apache-2.0" + }, { "name": "amc-listing", "description": "Build and maintain an Agentic Media Company listing in Interchange.", diff --git a/README.md b/README.md index 337a6c1..2b948c0 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,48 @@ -# Interchange plugin marketplace +# Apostra developer marketplace -Install only the workflow packages relevant to your role. Packages select -instructions and starter workflows; they do not grant account permissions. -Interchange OAuth and server-side entitlements remain authoritative. +Connect a coding agent to Apostra, verify the account, and build an advertising +workflow. Start with `apostra-developer`; install the narrower operating +packages only when you need them. Packages do not grant account permissions. +Apostra OAuth and server-side entitlements remain authoritative. -## Install +## Install in Codex + +```sh +codex plugin marketplace add scope3data/interchange-plugin +codex plugin add apostra-developer@interchange-plugin +``` + +The native plugin installs both the `build-with-apostra` skill and the Apostra +MCP connection. Complete OAuth when prompted, then ask Codex: "Verify my +Apostra account and help me build a seller discovery prototype." + +## Install in Claude Code ``` /plugin marketplace add scope3data/interchange-plugin -/plugin install buyer-campaign-management@interchange-plugin +/plugin install apostra-developer@interchange-plugin ``` Complete OAuth when prompted. Never paste API keys or provider credentials into a prompt. +## Install the skill in another compatible agent + +```sh +npx skills add scope3data/interchange-plugin \ + --skill build-with-apostra \ + --yes +``` + +This route installs the instructions only. Connect the agent's MCP client to +`https://api.interchange.io/mcp/v3`, complete OAuth, and run the read-only +`get_status` check before building. + ## Packages | Package | Purpose | Current canonical skills | |---|---|---| +| `apostra-developer` | Plan, run, automate, and report on advertising across sellers, or build those jobs into an application. | `build-with-apostra@1.0.0` | | `amc-listing` | Build and maintain an Agentic Media Company listing in Interchange. | MCP access; workflow skill forthcoming | | `amc-merchandising` | Merchandise an Agentic Media Company's inventory and products in Interchange. | MCP access; workflow skill forthcoming | | `amc-distribution` | Prepare and distribute an Agentic Media Company through supported agent channels. | `publish-an-openai-app@1.0.0` | @@ -37,8 +62,8 @@ Skill files are composed from the canonical, versioned Interchange skill registry. When a registry current-version pointer or referenced asset changes, the source workflow opens a reviewed sync PR for this repository. -Every durable change remains confirmation-gated by the underlying Interchange -tool contract. Full documentation lives at https://docs.interchange.io. +Every durable change remains confirmation-gated by the underlying Apostra +tool contract. Build guides live at https://docs.interchange.io/v3/overview. ## License diff --git a/SOURCE_DIGEST b/SOURCE_DIGEST index 0a5800c..7f02f6d 100644 --- a/SOURCE_DIGEST +++ b/SOURCE_DIGEST @@ -1 +1 @@ -689f2de18ba359a8c6faac22b53d6ad2fcb31320c66c135951733ae6b4a33d2a +2e4a53c3a0f1362322cb2dc398125123580d31198330264fa516f33a41cc5dfa diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..51760d9 --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,21 @@ +# Apostra Python starter + +This Python 3.11+ starter connects to Apostra's v3 MCP endpoint with a scoped +access token and makes one read-only `get_status` call. Connection, +verification, and session teardown share a 15-second deadline. The starter +cancels SDK tasks and closes HTTP connections before returning. On timeout, +check network access and the MCP endpoint, then retry. For browser OAuth, use the +[direct MCP quickstart](https://docs.interchange.io/v3/quickstart). + +```sh +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +APOSTRA_ACCESS_TOKEN='your-scoped-token' python main.py +python -m unittest test_main.py +``` + +Keep the token in an environment variable or secret manager. Do not commit it. +After verification works, follow the +[build-an-agent guide](https://docs.interchange.io/v3/build-an-agent) to add +tool discovery, durable checkpoints, confirmation gates, and retries. diff --git a/examples/python/main.py b/examples/python/main.py new file mode 100644 index 0000000..bd1057c --- /dev/null +++ b/examples/python/main.py @@ -0,0 +1,70 @@ +"""Verify an Apostra account through the v3 MCP endpoint.""" + +import asyncio +import json +import os +from typing import Any, Protocol + +import anyio + +IO_TIMEOUT_SECONDS = 15 + +APOSTRA_MCP_URL = "https://api.interchange.io/mcp/v3" + + +class ToolClient(Protocol): + async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: ... + + +async def verify_apostra_account( + client: ToolClient, timeout: float = IO_TIMEOUT_SECONDS +) -> Any: + """Make exactly one read-only call and fail closed on a tool error.""" + try: + result = await asyncio.wait_for(client.call_tool("get_status", {}), timeout) + except asyncio.TimeoutError as error: + raise RuntimeError( + "Apostra verification timed out. Check network access and retry." + ) from error + if result.isError or result.structuredContent is None: + raise RuntimeError("Apostra account verification failed") + return result.structuredContent + + +async def connect_and_verify(token: str, endpoint: str = APOSTRA_MCP_URL) -> Any: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + headers = {"Authorization": f"Bearer {token}"} + async with streamablehttp_client(endpoint, headers=headers) as streams: + read_stream, write_stream, _ = streams + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + return await verify_apostra_account(session) + + +async def run_verification( + token: str, timeout: float = IO_TIMEOUT_SECONDS, + endpoint: str = APOSTRA_MCP_URL +) -> Any: + # Level cancellation reaches every SDK task and every teardown await. + # asyncio.wait_for only cancels once, so SDK cleanup could outlive it. + try: + with anyio.fail_after(timeout): + return await connect_and_verify(token, endpoint) + except asyncio.TimeoutError as error: + raise RuntimeError( + "Apostra connection or verification timed out. " + "Check network access and the MCP endpoint, then retry." + ) from error + + +async def main() -> None: + token = os.environ.get("APOSTRA_ACCESS_TOKEN") + if not token: + raise RuntimeError("Set APOSTRA_ACCESS_TOKEN before running") + print(json.dumps(await run_verification(token), indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/requirements.txt b/examples/python/requirements.txt new file mode 100644 index 0000000..f1ee4ad --- /dev/null +++ b/examples/python/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.26,<2 +anyio>=4.5,<5 diff --git a/examples/python/test_main.py b/examples/python/test_main.py new file mode 100644 index 0000000..0b8e203 --- /dev/null +++ b/examples/python/test_main.py @@ -0,0 +1,194 @@ +import asyncio +import importlib +import json +import time +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from main import run_verification, verify_apostra_account + + +class FakeClient: + def __init__(self, result): + self.result = result + self.calls = [] + + async def call_tool(self, name, arguments): + self.calls.append((name, arguments)) + return self.result + + +class VerifyAccountTest(unittest.IsolatedAsyncioTestCase): + async def test_verifies_with_one_read_only_call(self): + expected = {"activeAccount": {"id": "buyer-123", "kind": "buyer"}} + client = FakeClient( + SimpleNamespace(isError=False, structuredContent=expected) + ) + + status = await verify_apostra_account(client) + + self.assertEqual(status, expected) + self.assertEqual(client.calls, [("get_status", {})]) + + async def test_fails_closed_on_tool_error(self): + client = FakeClient(SimpleNamespace(isError=True, structuredContent=None)) + with self.assertRaisesRegex(RuntimeError, "verification failed"): + await verify_apostra_account(client) + + async def test_cancels_stalled_tool_call(self): + cancelled = asyncio.Event() + + class StalledClient: + async def call_tool(self, name, arguments): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with self.assertRaisesRegex(RuntimeError, "verification timed out.*retry"): + await verify_apostra_account(StalledClient(), timeout=0.01) + self.assertTrue(cancelled.is_set()) + + async def test_cancels_stalled_connection(self): + cancelled = asyncio.Event() + + async def stalled_connect(*args): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with ( + patch("main.connect_and_verify", stalled_connect), + self.assertRaisesRegex(RuntimeError, "connection or verification timed out.*retry"), + ): + await run_verification("local-test-token", timeout=0.01) + self.assertTrue(cancelled.is_set()) + + async def test_bounds_stalled_http_connection(self): + # Keep module import time outside the short network deadline. + importlib.import_module("mcp.client.streamable_http") + closed = asyncio.Event() + + async def stalled_server(reader, writer): + try: + await reader.read() + finally: + writer.close() + await writer.wait_closed() + closed.set() + + server = await asyncio.start_server(stalled_server, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + try: + with self.assertRaisesRegex(RuntimeError, "connection or verification timed out.*retry"): + await asyncio.wait_for( + run_verification("local-test-token", timeout=0.5, + endpoint=f"http://127.0.0.1:{port}/mcp"), + timeout=2, + ) + await asyncio.wait_for(closed.wait(), timeout=2) + finally: + server.close() + await server.wait_closed() + + async def assert_bounded_session(self, stall_tool): + importlib.import_module("mcp.client.streamable_http") + writers = set() + handlers = set() + methods = set() + baseline = asyncio.all_tasks() + + async def server_handler(reader, writer): + writers.add(writer) + handlers.add(asyncio.current_task()) + try: + while True: + header = await reader.readuntil(b"\r\n\r\n") + lines = header.decode().split("\r\n") + method = lines[0].split()[0] + methods.add(method) + headers = dict(line.lower().split(": ", 1) for line in lines[1:] if ": " in line) + body = await reader.readexactly(int(headers.get("content-length", 0))) + if method == "GET": + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n: waiting\n\n") + await writer.drain() + await reader.read() + return + if method == "DELETE": + await reader.read() + return + message = json.loads(body) + methods.add(message["method"]) + if message["method"] == "tools/call" and stall_tool: + await reader.read() + return + if "id" not in message: + writer.write(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") + else: + if message["method"] == "initialize": + result = { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "local", "version": "1"}, + } + elif message["method"] == "tools/list": + result = {"tools": [{ + "name": "get_status", + "inputSchema": {"type": "object"}, + }]} + else: + result = {"content": [], "structuredContent": {"activeAccount": {"id": "local"}}} + payload = json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}).encode() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMcp-Session-Id: local-session\r\n" + + f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload) + await writer.drain() + except (asyncio.IncompleteReadError, ConnectionError): + pass + finally: + writer.close() + await writer.wait_closed() + writers.discard(writer) + handlers.discard(asyncio.current_task()) + + server = await asyncio.start_server(server_handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + operation = asyncio.create_task(run_verification( + "local-test-token", timeout=0.25, endpoint=f"http://127.0.0.1:{port}/mcp")) + started = time.monotonic() + try: + # asyncio.wait observes the bound without trying to cancel a stuck cleanup. + done, _ = await asyncio.wait({operation}, timeout=0.75) + self.assertIn(operation, done, "public function exceeded its end-to-end bound") + with self.assertRaisesRegex(RuntimeError, "timed out.*retry"): + operation.result() + self.assertLess(time.monotonic() - started, 0.75) + self.assertIn("tools/call", methods) + self.assertIn("GET", methods) + if not stall_tool: + self.assertIn("DELETE", methods) + for _ in range(20): + await asyncio.sleep(0.01) + self.assertFalse(writers, "verification left network connections open") + self.assertFalse(asyncio.all_tasks() - baseline - handlers, + "verification left SDK tasks running") + finally: + for writer in list(writers): + writer.close() + if not operation.done(): + operation.cancel() + await asyncio.gather(operation, return_exceptions=True) + await asyncio.gather(*handlers, return_exceptions=True) + server.close() + await server.wait_closed() + + async def test_closes_stalled_tool_and_get_stream(self): + await self.assert_bounded_session(stall_tool=True) + + async def test_closes_stalled_delete_and_get_stream(self): + await self.assert_bounded_session(stall_tool=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/typescript/README.md b/examples/typescript/README.md new file mode 100644 index 0000000..81efc8c --- /dev/null +++ b/examples/typescript/README.md @@ -0,0 +1,19 @@ +# Apostra TypeScript starter + +This Node.js 20.18.1+ starter connects to Apostra's v3 MCP endpoint with a scoped +access token and makes one read-only `get_status` call. The 15-second deadline +covers connection, verification, and session teardown. The starter closes its +HTTP connections before returning. On timeout, check network access and the MCP +endpoint, then retry. For browser OAuth, use the +[direct MCP quickstart](https://docs.interchange.io/v3/quickstart). + +```sh +npm install +APOSTRA_ACCESS_TOKEN='your-scoped-token' npm start +npm test +``` + +Keep the token in an environment variable or secret manager. Do not commit it. +After verification works, follow the +[build-an-agent guide](https://docs.interchange.io/v3/build-an-agent) to add +tool discovery, durable checkpoints, confirmation gates, and retries. diff --git a/examples/typescript/package.json b/examples/typescript/package.json new file mode 100644 index 0000000..91e0be0 --- /dev/null +++ b/examples/typescript/package.json @@ -0,0 +1,20 @@ +{ + "name": "apostra-typescript-starter", + "private": true, + "type": "module", + "engines": { + "node": ">=20.18.1" + }, + "scripts": { + "start": "tsx src/main.ts", + "test": "tsx --test src/main.test.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "undici": "^7.29.1" + }, + "devDependencies": { + "tsx": "^4.20.6", + "typescript": "^6.0.3" + } +} diff --git a/examples/typescript/src/main.test.ts b/examples/typescript/src/main.test.ts new file mode 100644 index 0000000..3409ecb --- /dev/null +++ b/examples/typescript/src/main.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { Socket } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import test from "node:test"; + +import { + connectAndVerify, + verifyApostraAccount, + type ToolClient, +} from "./main.js"; + +test("verifies the account with one read-only call", async () => { + const calls: string[] = []; + const client: ToolClient = { + async callTool(input) { + calls.push(input.name); + return { + structuredContent: { + activeAccount: { id: "buyer-123", kind: "buyer" }, + }, + }; + }, + }; + + const status = await verifyApostraAccount(client); + + assert.deepEqual(calls, ["get_status"]); + assert.deepEqual(status, { + activeAccount: { id: "buyer-123", kind: "buyer" }, + }); +}); + +test("fails closed when get_status does not return structured content", async () => { + const client: ToolClient = { + async callTool() { + return { isError: true }; + }, + }; + + await assert.rejects( + verifyApostraAccount(client), + /Apostra account verification failed/, + ); +}); + +test("cancels a stalled verification call", async () => { + let signal: AbortSignal | undefined; + const client: ToolClient = { + callTool(_input, _schema, options) { + signal = options?.signal; + return new Promise(() => {}); + }, + }; + await assert.rejects(verifyApostraAccount(client, 20), /timed out.*retry/); + assert.equal(signal?.aborted, true); +}); + +test("bounds a stalled HTTP connection", { timeout: 2000 }, async () => { + const server = createServer(() => {}); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + try { + await assert.rejects( + connectAndVerify( + "local-test-token", + 50, + `http://127.0.0.1:${address.port}/mcp`, + ), + /timed out.*retry/, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +for (const stallTool of [true, false]) { + test( + `closes all HTTP connections when ${stallTool ? "tools/call" : "DELETE teardown"} stalls`, + { timeout: 3000 }, + async () => { + const sockets = new Set(); + const methods = new Set(); + const server = createServer(async (request, response) => { + methods.add(request.method ?? ""); + if (request.method === "GET") { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(": waiting\n\n"); + return; + } + if (request.method === "DELETE") return; + let body = ""; + for await (const chunk of request) body += chunk; + const message = JSON.parse(body); + methods.add(message.method); + if (message.method === "tools/call" && stallTool) return; + if (message.id === undefined) { + response.writeHead(202).end(); + return; + } + if (message.method === "initialize" && stallTool) await delay(120); + response.writeHead(200, { + "content-type": "application/json", + "mcp-session-id": "local-session", + }); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: + message.method === "initialize" + ? { + protocolVersion: "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "local", version: "1" }, + } + : { + content: [], + structuredContent: { activeAccount: { id: "local" } }, + }, + }), + ); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const started = performance.now(); + try { + await assert.rejects( + connectAndVerify( + "local-test-token", + 250, + `http://127.0.0.1:${address.port}/mcp`, + ), + /timed out.*retry/, + ); + assert.ok( + performance.now() - started < 750, + "public function exceeded its end-to-end bound", + ); + assert.ok(methods.has("tools/call")); + assert.ok(methods.has("GET")); + if (!stallTool) assert.ok(methods.has("DELETE")); + // Allow the server to observe remote socket closure, without closing it ourselves. + for (let i = 0; i < 10; i++) await delay(5); + assert.equal( + sockets.size, + 0, + "verification left network connections open", + ); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); +} diff --git a/examples/typescript/src/main.ts b/examples/typescript/src/main.ts new file mode 100644 index 0000000..3686551 --- /dev/null +++ b/examples/typescript/src/main.ts @@ -0,0 +1,129 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Agent } from "undici"; + +export const IO_TIMEOUT_MS = 15_000; + +export const APOSTRA_MCP_URL = "https://api.interchange.io/mcp/v3"; + +export interface ToolClient { + callTool( + input: { name: string; arguments: Record }, + schema?: undefined, + options?: { signal: AbortSignal; timeout: number }, + ): Promise>; +} + +export async function verifyApostraAccount( + client: ToolClient, + timeoutMs = IO_TIMEOUT_MS, +): Promise { + const result = await withDeadline( + (signal) => + client.callTool({ name: "get_status", arguments: {} }, undefined, { + signal, + timeout: timeoutMs, + }), + timeoutMs, + ); + if (result.isError || result.structuredContent === undefined) { + throw new Error("Apostra account verification failed"); + } + return result.structuredContent; +} + +async function withDeadline( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error( + "Apostra connection or verification timed out. Check network access and the MCP endpoint, then retry.", + ); + controller.abort(error); + reject(error); + }, timeoutMs); + }); + try { + return await Promise.race([operation(controller.signal), deadline]); + } finally { + clearTimeout(timer); + } +} + +export async function connectAndVerify( + token: string, + timeoutMs = IO_TIMEOUT_MS, + endpoint = APOSTRA_MCP_URL, +): Promise { + const url = new URL(endpoint); + const lifetime = new AbortController(); + const timeoutError = new Error( + "Apostra connection or verification timed out. Check network access and the MCP endpoint, then retry.", + ); + const timer = setTimeout(() => lifetime.abort(timeoutError), timeoutMs); + const pending = new Set>(); + const dispatcher = new Agent(); + const client = new Client({ + name: "apostra-typescript-starter", + version: "1.0.0", + }); + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + // The SDK replaces requestInit.signal. Bind the lifetime at the actual + // fetch boundary so POST, GET, DELETE and cancellation notices all stop. + fetch: (url, init) => { + const options: RequestInit & { dispatcher: Agent } = { + ...init, + dispatcher, + signal: AbortSignal.any([ + lifetime.signal, + ...(init?.signal ? [init.signal] : []), + ]), + }; + const request = fetch(url, options); + pending.add(request); + void request.then( + () => pending.delete(request), + () => pending.delete(request), + ); + return request; + }, + }); + try { + await client.connect(transport, { + signal: lifetime.signal, + timeout: timeoutMs, + }); + const status = await verifyApostraAccount(client, timeoutMs); + await transport.terminateSession(); + return status; + } catch (error) { + if (lifetime.signal.aborted) throw timeoutError; + throw error; + } finally { + lifetime.abort(); + clearTimeout(timer); + try { + await client.close(); + } finally { + // Own the pool: aborting fetch alone can leave replacement/idle sockets. + // destroy() closes them locally, without waiting for the remote peer. + await dispatcher.destroy(); + await Promise.allSettled(pending); + } + } +} + +async function main(): Promise { + const token = process.env.APOSTRA_ACCESS_TOKEN; + if (!token) throw new Error("Set APOSTRA_ACCESS_TOKEN before running"); + console.log(JSON.stringify(await connectAndVerify(token), null, 2)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await main(); +} diff --git a/plugins/amc-campaign-management/.claude-plugin/plugin.json b/plugins/amc-campaign-management/.claude-plugin/plugin.json index edf50c1..9bec515 100644 --- a/plugins/amc-campaign-management/.claude-plugin/plugin.json +++ b/plugins/amc-campaign-management/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "amc-campaign-management", "displayName": "AMC Campaign Management", "description": "Operate seller-side campaigns and delivery in Interchange.", - "version": "1.0.0+8956d0aeaf6e", + "version": "1.0.0+ab6f45c1d871", "author": { "name": "Scope3 PBC", "url": "https://interchange.io" diff --git a/plugins/amc-campaign-management/.codex-plugin/plugin.json b/plugins/amc-campaign-management/.codex-plugin/plugin.json new file mode 100644 index 0000000..64583ec --- /dev/null +++ b/plugins/amc-campaign-management/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "amc-campaign-management", + "version": "1.0.0+ab6f45c1d871", + "description": "Operate seller-side campaigns and delivery in Interchange.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "advertising", + "campaigns", + "delivery", + "mcp" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "AMC Campaign Management", + "shortDescription": "Operate seller-side campaigns and delivery in Interchange.", + "longDescription": "Operate seller-side campaigns and delivery in Interchange.", + "developerName": "Scope3 PBC", + "category": "Productivity", + "capabilities": [ + "Apostra MCP" + ], + "websiteURL": "https://docs.interchange.io", + "defaultPrompt": [ + "Help me use AMC Campaign Management with Apostra." + ], + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/directory-icon.png" + } +} diff --git a/plugins/amc-distribution/.claude-plugin/plugin.json b/plugins/amc-distribution/.claude-plugin/plugin.json index 33d1433..13d273c 100644 --- a/plugins/amc-distribution/.claude-plugin/plugin.json +++ b/plugins/amc-distribution/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "amc-distribution", "displayName": "AMC Distribution", "description": "Prepare and distribute an Agentic Media Company through supported agent channels.", - "version": "1.0.0+5156e005ef0d", + "version": "1.0.0+886dc10e672b", "author": { "name": "Scope3 PBC", "url": "https://interchange.io" diff --git a/plugins/amc-distribution/.codex-plugin/plugin.json b/plugins/amc-distribution/.codex-plugin/plugin.json new file mode 100644 index 0000000..a90f617 --- /dev/null +++ b/plugins/amc-distribution/.codex-plugin/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "amc-distribution", + "version": "1.0.0+886dc10e672b", + "description": "Prepare and distribute an Agentic Media Company through supported agent channels.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "advertising", + "distribution", + "mcp" + ], + "mcpServers": "./.mcp.json", + "skills": "./skills/", + "interface": { + "displayName": "AMC Distribution", + "shortDescription": "Prepare and distribute an Agentic Media Company through supported agent channels.", + "longDescription": "Prepare and distribute an Agentic Media Company through supported agent channels.", + "developerName": "Scope3 PBC", + "category": "Deployment", + "capabilities": [ + "Apostra MCP", + "Agent skill" + ], + "websiteURL": "https://docs.interchange.io", + "defaultPrompt": [ + "Help me use AMC Distribution with Apostra." + ], + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/directory-icon.png" + } +} diff --git a/plugins/amc-listing/.claude-plugin/plugin.json b/plugins/amc-listing/.claude-plugin/plugin.json index c8f539a..da12937 100644 --- a/plugins/amc-listing/.claude-plugin/plugin.json +++ b/plugins/amc-listing/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "amc-listing", "displayName": "AMC Listing", "description": "Build and maintain an Agentic Media Company listing in Interchange.", - "version": "1.0.0+170e8d2436ba", + "version": "1.0.0+4b0705da1792", "author": { "name": "Scope3 PBC", "url": "https://interchange.io" diff --git a/plugins/amc-listing/.codex-plugin/plugin.json b/plugins/amc-listing/.codex-plugin/plugin.json new file mode 100644 index 0000000..035af5d --- /dev/null +++ b/plugins/amc-listing/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "amc-listing", + "version": "1.0.0+4b0705da1792", + "description": "Build and maintain an Agentic Media Company listing in Interchange.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "advertising", + "listing", + "mcp" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "AMC Listing", + "shortDescription": "Build and maintain an Agentic Media Company listing in Interchange.", + "longDescription": "Build and maintain an Agentic Media Company listing in Interchange.", + "developerName": "Scope3 PBC", + "category": "Productivity", + "capabilities": [ + "Apostra MCP" + ], + "websiteURL": "https://docs.interchange.io", + "defaultPrompt": [ + "Help me use AMC Listing with Apostra." + ], + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/directory-icon.png" + } +} diff --git a/plugins/amc-merchandising/.claude-plugin/plugin.json b/plugins/amc-merchandising/.claude-plugin/plugin.json index ca2caf7..8f64055 100644 --- a/plugins/amc-merchandising/.claude-plugin/plugin.json +++ b/plugins/amc-merchandising/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "amc-merchandising", "displayName": "AMC Merchandising", "description": "Merchandise an Agentic Media Company's inventory and products in Interchange.", - "version": "1.0.0+6128c7645abd", + "version": "1.0.0+0630ecb58bb1", "author": { "name": "Scope3 PBC", "url": "https://interchange.io" diff --git a/plugins/amc-merchandising/.codex-plugin/plugin.json b/plugins/amc-merchandising/.codex-plugin/plugin.json new file mode 100644 index 0000000..cae8e5d --- /dev/null +++ b/plugins/amc-merchandising/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "amc-merchandising", + "version": "1.0.0+0630ecb58bb1", + "description": "Merchandise an Agentic Media Company's inventory and products in Interchange.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "advertising", + "inventory", + "products", + "mcp" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "AMC Merchandising", + "shortDescription": "Merchandise an Agentic Media Company's inventory and products in Interchange.", + "longDescription": "Merchandise an Agentic Media Company's inventory and products in Interchange.", + "developerName": "Scope3 PBC", + "category": "Productivity", + "capabilities": [ + "Apostra MCP" + ], + "websiteURL": "https://docs.interchange.io", + "defaultPrompt": [ + "Help me use AMC Merchandising with Apostra." + ], + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/directory-icon.png" + } +} diff --git a/plugins/amc-self-serve-buyer/.claude-plugin/plugin.json b/plugins/amc-self-serve-buyer/.claude-plugin/plugin.json index 5591a9b..8a8cc64 100644 --- a/plugins/amc-self-serve-buyer/.claude-plugin/plugin.json +++ b/plugins/amc-self-serve-buyer/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "amc-self-serve-buyer", "displayName": "AMC Self-Serve Buyer", "description": "Bundle buyer workflows for an Agentic Media Company's own self-serve plugin.", - "version": "1.0.0+78467a017992", + "version": "1.0.0+1af684d1fcc6", "author": { "name": "Scope3 PBC", "url": "https://interchange.io" diff --git a/plugins/amc-self-serve-buyer/.codex-plugin/plugin.json b/plugins/amc-self-serve-buyer/.codex-plugin/plugin.json new file mode 100644 index 0000000..c0c7557 --- /dev/null +++ b/plugins/amc-self-serve-buyer/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "amc-self-serve-buyer", + "version": "1.0.0+1af684d1fcc6", + "description": "Bundle buyer workflows for an Agentic Media Company's own self-serve plugin.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "advertising", + "buyer", + "self-serve", + "mcp" + ], + "mcpServers": "./.mcp.json", + "skills": "./skills/", + "interface": { + "displayName": "AMC Self-Serve Buyer", + "shortDescription": "Bundle buyer workflows for an Agentic Media Company's own self-serve plugin.", + "longDescription": "Bundle buyer workflows for an Agentic Media Company's own self-serve plugin.", + "developerName": "Scope3 PBC", + "category": "Productivity", + "capabilities": [ + "Apostra MCP", + "Agent skill" + ], + "websiteURL": "https://docs.interchange.io", + "defaultPrompt": [ + "Help me use AMC Self-Serve Buyer with Apostra." + ], + "composerIcon": "./assets/composer-icon.png", + "logo": "./assets/directory-icon.png" + } +} diff --git a/plugins/apostra-developer/.claude-plugin/plugin.json b/plugins/apostra-developer/.claude-plugin/plugin.json new file mode 100644 index 0000000..935dd54 --- /dev/null +++ b/plugins/apostra-developer/.claude-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "apostra-developer", + "displayName": "Apostra Developer", + "description": "Plan, run, automate, and report on advertising across sellers, or build those jobs into an application.", + "version": "1.0.0+01db25a53c32", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io/v3/overview", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "apostra", + "advertising", + "campaign automation", + "growth marketing", + "media buying", + "reporting", + "developer", + "mcp", + "agents" + ], + "mcpServers": "./.mcp.json", + "skills": "./skills/" +} diff --git a/plugins/apostra-developer/.codex-plugin/plugin.json b/plugins/apostra-developer/.codex-plugin/plugin.json new file mode 100644 index 0000000..9520a1c --- /dev/null +++ b/plugins/apostra-developer/.codex-plugin/plugin.json @@ -0,0 +1,43 @@ +{ + "name": "apostra-developer", + "version": "1.0.0+01db25a53c32", + "description": "Plan, run, automate, and report on advertising across sellers, or build those jobs into an application.", + "author": { + "name": "Scope3 PBC", + "url": "https://interchange.io" + }, + "homepage": "https://docs.interchange.io/v3/overview", + "repository": "https://github.com/scope3data/interchange-plugin", + "license": "Apache-2.0", + "keywords": [ + "apostra", + "advertising", + "campaign automation", + "growth marketing", + "media buying", + "reporting", + "developer", + "mcp", + "agents" + ], + "mcpServers": "./.mcp.json", + "skills": "./skills/", + "interface": { + "displayName": "Apostra Developer", + "shortDescription": "Connect Apostra and build a tested integration.", + "longDescription": "Plan, run, automate, and report on advertising across sellers, or build those jobs into an application.", + "developerName": "Scope3 PBC", + "category": "Development", + "capabilities": [ + "Apostra MCP", + "Agent skill" + ], + "websiteURL": "https://docs.interchange.io/v3/overview", + "defaultPrompt": [ + "Connect this repository to Apostra and build a tested seller discovery prototype." + ], + "brandColor": "#7513D4", + "composerIcon": "./assets/apostra-icon-purple.svg", + "logo": "./assets/apostra-icon-purple.svg" + } +} diff --git a/plugins/apostra-developer/.mcp.json b/plugins/apostra-developer/.mcp.json new file mode 100644 index 0000000..613afa9 --- /dev/null +++ b/plugins/apostra-developer/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "interchange": { + "type": "http", + "url": "https://api.interchange.io/mcp/v3" + } + } +} diff --git a/plugins/apostra-developer/assets/apostra-icon-purple.svg b/plugins/apostra-developer/assets/apostra-icon-purple.svg new file mode 100644 index 0000000..3b2c9e5 --- /dev/null +++ b/plugins/apostra-developer/assets/apostra-icon-purple.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/apostra-developer/assets/composer-icon.png b/plugins/apostra-developer/assets/composer-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..89d1cbaba9c4234aa6a7c65399d76315cbc57fe4 GIT binary patch literal 986 zcmV<0110>4P)>IT&P~{eoaz z7KMWlA*oNT85HuJAe|&$TzQ1tw8!cQN%{d~a`_bb$&jnww-Wk6T2$b~q+ z@@@b?0Vx+OlVPFd78r4@Go-GOb3tyn8q{2y9B@u5IN_DJyPEw2GQscW#a^47oP-XJ zd!d?LUx0DJyI_%nF;b;53S=?V9I-LlrwuZbCr^z3toyOp57S1k67r4bCqu?MMxyrh z&jl}H$RWX4hwn=4Ve2C#;TOSpS4c6ox52i!Se>slYJFw6n86B1;u`1EiWhP_!9y-r zMy&(+uvn(qcDNbkQmM51Ej~MAlfvhB&N%+bn<99w+*E6P;@@dLJ7udyQGS*a(cJZ} zRPaF(oj$`d&v?R0iw(Kqf}f2z>I#~I+=GspFVqfpxrU?VL!)DyFG5)Zj~c6Gfp!P{ zwTwj*8PF_q47u*AJVu{Hh<8D+EpMSA$gs`^>)dOe*+z{zZNy=xE9W0|#eyEhgT(o= z^F|}nPV=&lW8#u%IM$Pq#|1lJsV^(Lr!tv5CSXNQF=l%_n6}p5rsXOA6vO_E9? z&kf-|=W5KM>~qN*aAC$#6&?11&24_#U3`~ig(+^-3U;aBHL#~+SecrpHQ{QlHO&N< zt;QV=RDL>D{D$3DMe&$W^m5>Q1X^}g%TXLTT~p;=&g8Qe$6mt@-SYo_k4+Wt6%edP zEBel}-t(<9t~=PRT&W8v?C?@F-9)mrFdDQ&E!1ybltobHqKj2L9uSb=QrmMD-zM_T z#bSLxkQjBUZ5HMGt}(WL@|)sEr>6jBX!QWO%n++27j};ze+Q&Q3U>B zi@W*}hkesTmUi*KylFoAsLsmvj`Fa{EAb-ep0~{+(CV*7F53p*SJGt?d(n1F{txj) z11&H6+TRiLj~$lAWf@4kEa;li6PH`-0e9QyBwF&Ax(v|%0|N#9r@Yhtga7~l07*qo IM6N<$f_}f`IsgCw literal 0 HcmV?d00001 diff --git a/plugins/apostra-developer/assets/directory-icon.png b/plugins/apostra-developer/assets/directory-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..990566ceed7a2e17d1252453ad64a08142f5a3a8 GIT binary patch literal 8615 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