Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.10.15: six weekly-job prompts on /mcp

`/mcp` now serves six MCP prompts: `weekly-brand-watch`, `weekly-competitor-reads`, `weekly-person-tracker`, `weekly-sponsor-scout`, `weekly-show-digest` and `weekly-monitor-roundup`. Picking one runs the job once now. Then it gives back the job with the names filled in, and the steps to run it every week as a scheduled task.

- Each prompt takes one optional argument. With no names, or names that look cut off, the message asks for them first. Claude Code splits prompt arguments on whitespace, so a long list can arrive cut off.
- When a plan can't read the newest dates, the job says so in one line and runs the same search over the newest 7 days it can read.
- `/mcp/apps` serves no prompts. `tools/list` is unchanged on both paths.

## 0.10.14: Claude Code loads the reference, the program runner and feedback upfront

The server instructions tell an agent to call `arcmira_describe` before writing a program, but in Claude Code's default view `arcmira_describe` waited behind tool search, and so did `arcmira_feedback`. This release loads both upfront beside `arcmira_execute_read`, and routes the instructions to programs.
Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"mcpServers": {
"arcmira": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@arcmira/mcp",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"keywords": [
"mcp",
Expand Down
2 changes: 1 addition & 1 deletion plugins/arcmira/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"author": {
"name": "Arcmira",
Expand Down
2 changes: 1 addition & 1 deletion plugins/arcmira/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"author": {
"name": "Arcmira",
Expand Down
2 changes: 1 addition & 1 deletion plugins/arcmira/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"author": {
"name": "Arcmira",
Expand Down
2 changes: 1 addition & 1 deletion plugins/arcmira/gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"mcpServers": {
"arcmira": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/arcmira/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "arcmira",
"version": "0.10.14",
"version": "0.10.15",
"description": "YouTube transcript search and monitoring for your AI: exact quotes, full transcripts and speakers.",
"author": {
"name": "Arcmira",
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/arcmira/mcp",
"source": "github"
},
"version": "0.10.14",
"version": "0.10.15",
"websiteUrl": "https://arcmira.com",
"remotes": [
{
Expand Down
132 changes: 132 additions & 0 deletions src/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/server';

/**
* Weekly jobs a user picks as a slash command (`/arcmira:weekly-brand-watch` in Claude Code, the + menu in
* claude.ai). Each returns one user message: run the job once now, then hand back the job text and the steps to
* schedule it. The user creates the schedule; nothing here creates one or tells a host to.
*/
export interface WeeklyJob {
name: string;
title: string;
description: string;
arg: { name: string; description: string };
/** index: the last 7 days of search and mentions need a paid plan. transcripts: works on Free. */
reads: 'index' | 'transcripts';
/** Ad-read data is served in full on some plans only. */
sponsors?: true;
job: (value: string) => string;
}

const EACH = 'the exact line, who said it when Arcmira knows, the show, the date and the timestamped watch link';

export const WEEKLY_JOBS: readonly WeeklyJob[] = [
{
name: 'weekly-brand-watch',
title: 'Weekly brand watch',
description: 'A weekly job that finds what was said about your brands on YouTube and video podcasts, with the steps to schedule it.',
arg: { name: 'names', description: 'Brand or company names, separated by commas' },
reads: 'index',
job: (names) =>
`Use Arcmira to find what was said about ${names} on YouTube and video podcasts in the last 7 days. Leave out ad reads. Give me up to 5 moments per name. For each, show ${EACH}. If nothing new was said about a name, say so in one line.`,
},
{
name: 'weekly-competitor-reads',
title: 'Weekly competitor reads',
description: "A weekly job that splits competitors' YouTube ad reads from unpaid recommendations, with the steps to schedule it.",
arg: { name: 'names', description: 'Competitor names, separated by commas' },
reads: 'index',
sponsors: true,
job: (names) =>
`Use Arcmira to find ad reads and unpaid recommendations for ${names} on YouTube and video podcasts in the last 7 days. Split them into paid reads and organic mentions. For each, show the show, the date, the exact line, any promo code and the timestamped watch link. Flag any show that read an ad for one of them for the first time in 90 days.`,
},
{
name: 'weekly-person-tracker',
title: 'Weekly person tracker',
description: 'A weekly job that collects what a person said, and what was said about them, on YouTube and video podcasts, with the steps to schedule it.',
arg: { name: 'names', description: "People's names, separated by commas" },
reads: 'index',
job: (names) =>
`Use Arcmira to find what ${names} said on YouTube and video podcasts in the last 7 days, and what others said about them. For each quote, show ${EACH}. Mark which lines are theirs and which are about them.`,
},
{
name: 'weekly-sponsor-scout',
title: 'Weekly sponsor scout',
description: 'A weekly job that lists the brands that read ads on chosen YouTube channels and which are new, with the steps to schedule it.',
arg: { name: 'channels', description: 'YouTube channel names or URLs, separated by commas' },
reads: 'index',
sponsors: true,
job: (channels) =>
`Use Arcmira to find which brands read ads on these YouTube channels in the last 7 days: ${channels}. Mark the brands that are new to each channel. For each read, show the brand, the channel, the date, the exact line and the timestamped watch link.`,
},
{
name: 'weekly-show-digest',
title: 'Weekly show digest',
description: 'A weekly job that reads the new episodes of chosen shows and returns their most quotable lines, with the steps to schedule it.',
arg: { name: 'shows', description: 'Show or channel names, separated by commas' },
reads: 'transcripts',
job: (shows) =>
`Find the episodes these shows published on YouTube in the last 7 days: ${shows}. Use Arcmira's episode list, or a web search when that list doesn't reach this week. Read each new episode's transcript with Arcmira. For each episode, give me one sentence on what it covered and its 3 most quotable lines, each with the timestamped watch link.`,
},
{
name: 'weekly-monitor-roundup',
title: 'Weekly monitor roundup',
description: 'A weekly job that rounds up the last 7 days for every name your Arcmira monitors follow, with the steps to schedule it.',
arg: { name: 'slack_channel', description: 'Optional Slack channel to post the roundup to, such as #market-intel' },
reads: 'index',
job: (channel) =>
`List my Arcmira monitors and the names each one follows. For each name, find what was said on YouTube and video podcasts in the last 7 days. Group the moments by name, at most 3 per name, each with ${EACH}.` +
(channel ? ` Post the roundup to ${channel} with my Slack connector.` : ''),
},
];

/** Checked against the Claude Code scheduled-tasks docs on 2026-11-07. */
export const SCHEDULE_STEPS = [
'In Claude: open claude.ai/code/routines, click New task, paste the job, choose a weekly schedule, and pick a time a few minutes past the hour, such as 9:07. In Claude Code, /schedule does the same.',
"Scheduled runs use claude.ai connectors. Add Arcmira at claude.ai/customize/connectors if it isn't there. A server added only with `claude mcp add` doesn't appear in scheduled runs.",
'Scheduled tasks need a paid Claude plan. Each run also uses credits from my Arcmira plan.',
"On another assistant, tell me how it schedules a task, or say it can't.",
];

const SPONSOR_NOTE = "If Arcmira says my plan can't read this, say so in one line and use what it did return.";

const FREE_INDEX_NOTE =
"If Arcmira says my plan can't read those dates, say so in one line, then run the same search over the newest 7 days it can read.";

export function weeklyJobMessage(job: WeeklyJob, value: string): string {
const given = value.trim();
const optional = job.name === 'weekly-monitor-roundup';
const job_text =
job.job(given || (optional ? '' : `{${job.arg.name}}`)) +
(job.reads === 'index' ? ` ${FREE_INDEX_NOTE}` : '') +
(job.sponsors ? ` ${SPONSOR_NOTE}` : '');
return [
`I want a weekly Arcmira job: ${job.title}.`,
optional ? '' : `${job.arg.description}: ${given || '(none given)'}. If none are given, or they look cut off, ask me for them before anything else.`,
'1. Run the job below once now, so I can see what a week of it looks like.',
'2. Then give me the job as one block I can paste into a scheduled task, with my names filled in.',
'3. Then show me these steps for running it every week:',
...SCHEDULE_STEPS.map((step) => ` - ${step}`),
'',
'The job:',
job_text,
]
.filter((line, i) => line !== '' || i > 1)
.join('\n');
}

export function registerWeeklyJobs(server: McpServer): void {
for (const job of WEEKLY_JOBS) {
server.registerPrompt(
job.name,
{
title: job.title,
description: job.description,
argsSchema: z.object({ [job.arg.name]: z.string().optional().describe(job.arg.description) }),
},
(args: Record<string, string | undefined>) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: weeklyJobMessage(job, args[job.arg.name] ?? '') } }],
}),
);
}
}
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { clientLabel, errorResult, withBuild, withRateLimit } from './result.ts'
import type { Access, SandboxHost } from './sandbox.ts';
import { callId, inputState, outline, type Recorder } from './telemetry.ts';
import { APPS_TOOLS, RETIRED_TOOLS, SERVER_INSTRUCTIONS, TOOLS, retiredToolResult, type AnyToolSpec } from './tools.ts';
import { registerWeeklyJobs } from './prompts.ts';

export const MCP_PATH = '/mcp';
export const APPS_PATH = '/mcp/apps';
Expand Down Expand Up @@ -103,6 +104,7 @@ export function createServer(caller: Caller, deploy: string | null = null, surfa
},
);
}
if (surface.path === MCP_PATH) registerWeeklyJobs(server);
return server;
}

Expand Down
96 changes: 96 additions & 0 deletions tests/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import worker from '../src/index.ts';
import { RULE_PUSH } from '../src/reference.ts';
import { WEEKLY_JOBS } from '../src/prompts.ts';
import { TOOLS } from '../src/tools.ts';

/** One JSON-RPC call through the Worker's fetch, the way a host sends it. */
async function rpc(method: string, params: Record<string, unknown> = {}, path = '/mcp') {
const request = new Request(`https://mcp.arcmira.com${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: 'Bearer arc_sk_fixture' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const ctx = { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext;
const original = globalThis.fetch;
globalThis.fetch = (async () => Response.json({ id: 'caller' })) as typeof fetch;
try {
const text = await (await worker.fetch(request, {}, ctx)).text();
const data = text.split('\n').find((line) => line.startsWith('data:'));
return JSON.parse(data ? data.slice(5) : text);
} finally {
globalThis.fetch = original;
}
}

const textOf = (reply: { result: { messages: Array<{ content: { text: string } }> } }) => reply.result.messages.map((m) => m.content.text).join('\n');

describe('weekly-job prompts', () => {
it('initialize advertises prompts', async () => {
const reply = await rpc('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1' } });
assert.ok(reply.result.capabilities.prompts, JSON.stringify(reply.result.capabilities));
});

it('prompts/list returns the six jobs, each with one optional argument', async () => {
const reply = await rpc('prompts/list');
const listed = reply.result.prompts as Array<{ name: string; title: string; description: string; arguments: Array<{ name: string; required?: boolean }> }>;
assert.deepEqual(listed.map((p) => p.name), WEEKLY_JOBS.map((j) => j.name));
for (const p of listed) {
assert.equal(p.arguments.length, 1, p.name);
assert.notEqual(p.arguments[0].required, true, p.name);
assert.doesNotMatch(p.description, RULE_PUSH, p.name);
}
});

it('prompts/get fills in the names, keeps the steps, and adds the Free note only to index jobs', async () => {
const brand = textOf(await rpc('prompts/get', { name: 'weekly-brand-watch', arguments: { names: 'Render, Fly.io' } }));
assert.match(brand, /about Render, Fly\.io on YouTube/);
assert.match(brand, /claude\.ai\/customize\/connectors/);
assert.match(brand, /claude\.ai\/code\/routines/);
assert.match(brand, /9:07/);
assert.match(brand, /my plan can't read those dates, say so in one line, then run the same search over the newest 7 days it can read\./);
assert.match(brand, /uses credits from my Arcmira plan/);
assert.match(brand, /On another assistant, tell me how it schedules a task/);
assert.doesNotMatch(brand, /Pro\+/);
const scout = textOf(await rpc('prompts/get', { name: 'weekly-sponsor-scout', arguments: { channels: 'TBPN' } }));
assert.match(scout, /If Arcmira says my plan can't read this, say so in one line and use what it did return\./);
const reads = textOf(await rpc('prompts/get', { name: 'weekly-competitor-reads', arguments: { names: 'Ramp' } }));
assert.match(reads, /my plan can't read this, say so in one line/);
assert.doesNotMatch(brand, /plan can't read this,/);
const digest = textOf(await rpc('prompts/get', { name: 'weekly-show-digest', arguments: { shows: 'Lex Fridman Podcast' } }));
assert.match(digest, /Read each new episode's transcript with Arcmira/);
assert.doesNotMatch(digest, /can't read those dates|Pro\+/);
});

it('with no names, the message asks for them first; the roundup needs none', async () => {
const bare = textOf(await rpc('prompts/get', { name: 'weekly-person-tracker', arguments: {} }));
assert.match(bare, /\(none given\)\. If none are given/);
assert.match(bare, /find what \{names\} said/);
const roundup = textOf(await rpc('prompts/get', { name: 'weekly-monitor-roundup', arguments: {} }));
assert.doesNotMatch(roundup, /none given|Slack connector/);
const slack = textOf(await rpc('prompts/get', { name: 'weekly-monitor-roundup', arguments: { slack_channel: '#market-intel' } }));
assert.match(slack, /Post the roundup to #market-intel with my Slack connector\./);
});

it('tools/list is unchanged and no tool text mentions scheduling', async () => {
const reply = await rpc('tools/list');
const tools = reply.result.tools as Array<{ name: string; description: string }>;
assert.deepEqual(tools.map((t) => t.name).sort(), TOOLS.map((t) => t.name).sort());
for (const t of tools) assert.doesNotMatch(t.description, /schedul|weekly job|routine/i, t.name);
});

it('names no plan tier and uses straight apostrophes', async () => {
for (const job of WEEKLY_JOBS) {
const text = textOf(await rpc('prompts/get', { name: job.name, arguments: { [job.arg.name]: 'Ramp' } }));
for (const s of [text, job.title, job.description, job.arg.description]) {
assert.doesNotMatch(s, /\bPro\b|Pro\+|Enterprise|\u2019|\u2018|\u201c|\u201d/, job.name);
}
}
});

it('/mcp/apps lists no prompts', async () => {
const reply = await rpc('prompts/list', {}, '/mcp/apps');
assert.equal(reply.result?.prompts?.length ?? 0, 0, JSON.stringify(reply));
});
});
Loading