feat(redroid-mcp): pluggable RecordingProfile; add people-service profile, bump 0.2.0

The factory was hard-wired to quinn (client_id arg + QUINN_MY_* env). Extract a
RecordingProfile { inputArg, buildEnv }: quinnRecordingProfile() is the unchanged
default (client_id → --client-id, QUINN_MY_*), so @whatsapp is byte-for-byte
unaffected; peopleRecordingProfile() forwards an optional `ref` (→ --ref) and injects
PEOPLE_* for mr-number (whose lookup script records the people-service signal itself).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 13:39:59 -04:00
parent 1c4eb95632
commit 65aa4b1989
2 changed files with 82 additions and 27 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@lilith/redroid-mcp",
"version": "0.1.0",
"version": "0.2.0",
"description": "Factory for the plum-local stdio MCP servers that wrap a redroid screening tool (mr-number, whatsapp) — generic spawn/token/dispatch/devices, parametrized by script path + tool defs.",
"type": "module",
"main": "./dist/index.js",

View file

@ -29,13 +29,32 @@ export interface ToolDef {
readonly description: string;
}
/**
* How an app records its verdict. Differs per app: the legacy quinn apps pass a
* numeric `client_id` and inject `QUINN_MY_*`; mr-number passes a string `ref` and
* injects `PEOPLE_*` (the lookup script records the people-service signal itself).
* The factory is agnostic it only forwards the tool-input arg as a CLI flag and
* injects the env this profile supplies.
*/
export interface RecordingProfile {
/** Optional tool-input field surfaced in the schema and forwarded as a CLI flag. */
readonly inputArg?: {
readonly key: string;
readonly type: 'number' | 'string';
readonly flag: string;
readonly description: string;
};
/** Env injected into the python process (base URLs + resolved service token). */
readonly buildEnv: () => NodeJS.ProcessEnv;
}
export interface RedroidMcpConfig {
/** MCP server name, e.g. `quinn-mr-number`. */
readonly serverName: string;
readonly version?: string;
/** Absolute path to the `*_lookup.py` script driven via `--json`. */
readonly script: string;
/** The lookup tool (name + description); takes `phone`, optional `client_id`, `dry_run`. */
/** The lookup tool (name + description); takes `phone`, the recording arg, `dry_run`. */
readonly lookupTool: ToolDef;
/** The devices tool (name + description); lists attached adb devices. */
readonly devicesTool: ToolDef;
@ -43,8 +62,8 @@ export interface RedroidMcpConfig {
readonly python?: string;
/** adb binary (default `$REDROID_ADB` || `adb`). */
readonly adb?: string;
/** quinn.my base URL (default `$QUINN_MY_URL` || prod). */
readonly quinnMyUrl?: string;
/** Recording behaviour (default: the legacy quinn.my profile). */
readonly recording?: RecordingProfile;
}
interface SpawnResult {
@ -64,18 +83,55 @@ function makeLogger(serverName: string) {
};
}
const TOKEN_FILE = join(homedir(), '.config/quinn-secrets/quinn-my.service-token');
/** Service token for recording: env wins, else the canonical plum secret file. */
function serviceToken(): string {
if (process.env['QUINN_MY_SERVICE_TOKEN']) return process.env['QUINN_MY_SERVICE_TOKEN'] as string;
/** Resolve a service token: env var wins, else a 0600 plum secret file. */
function tokenFromFileOrEnv(envVar: string, file: string): string {
if (process.env[envVar]) return process.env[envVar] as string;
try {
return readFileSync(TOKEN_FILE, 'utf8').trim();
return readFileSync(file, 'utf8').trim();
} catch {
return '';
}
}
/** Legacy quinn.my recording profile — the default (still used by @whatsapp). */
export function quinnRecordingProfile(): RecordingProfile {
return {
inputArg: {
key: 'client_id',
type: 'number',
flag: '--client-id',
description: 'quinn client id to record the verdict against. Omit to only preview.',
},
buildEnv: () => ({
QUINN_MY_URL: process.env['QUINN_MY_URL'] ?? 'https://my.transquinnftw.com',
QUINN_MY_SERVICE_TOKEN: tokenFromFileOrEnv(
'QUINN_MY_SERVICE_TOKEN',
join(homedir(), '.config/quinn-secrets/quinn-my.service-token'),
),
}),
};
}
/** Cocotte people-service recording profile the lookup script records the signal
* itself; here we only forward the optional `ref` and the PEOPLE_* env. */
export function peopleRecordingProfile(): RecordingProfile {
return {
inputArg: {
key: 'ref',
type: 'string',
flag: '--ref',
description: 'Optional requester correlation id (carried into the signal sourceHandle).',
},
buildEnv: () => ({
PEOPLE_BASE_URL: process.env['PEOPLE_BASE_URL'] ?? 'http://10.9.0.5:3061',
PEOPLE_SERVICE_TOKEN: tokenFromFileOrEnv(
'PEOPLE_SERVICE_TOKEN',
join(homedir(), '.config/cocotte-secrets/people-service.token'),
),
}),
};
}
function run(bin: string, args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<SpawnResult> {
return new Promise((resolve, reject) => {
const child = spawn(bin, args as string[], { env });
@ -101,19 +157,18 @@ function run(bin: string, args: readonly string[], env: NodeJS.ProcessEnv, timeo
type Args = Record<string, unknown>;
function buildTools(cfg: RedroidMcpConfig) {
const recording = cfg.recording ?? quinnRecordingProfile();
const arg = recording.inputArg;
const properties: Record<string, { type: 'string' | 'number' | 'boolean'; description: string }> = {
phone: { type: 'string', description: 'Phone number to look up (any format; cleaned before input).' },
dry_run: { type: 'boolean', description: 'Do the lookup + vision but do NOT record the verdict.' },
};
if (arg) properties[arg.key] = { type: arg.type, description: arg.description };
return [
{
name: cfg.lookupTool.name,
description: cfg.lookupTool.description,
inputSchema: {
type: 'object' as const,
properties: {
phone: { type: 'string' as const, description: 'Phone number to look up (any format; cleaned before input).' },
client_id: { type: 'number' as const, description: 'quinn client id to record the verdict against. Omit to only preview.' },
dry_run: { type: 'boolean' as const, description: 'Do the lookup + vision but do NOT record (even if client_id is given).' },
},
required: ['phone'],
},
inputSchema: { type: 'object' as const, properties, required: ['phone'] },
},
{
name: cfg.devicesTool.name,
@ -126,7 +181,7 @@ function buildTools(cfg: RedroidMcpConfig) {
async function dispatch(cfg: RedroidMcpConfig, name: string, args: Args): Promise<string> {
const adb = cfg.adb ?? process.env['REDROID_ADB'] ?? 'adb';
const python = cfg.python ?? process.env['REDROID_PYTHON'] ?? '/opt/homebrew/bin/python3';
const quinnMyUrl = cfg.quinnMyUrl ?? process.env['QUINN_MY_URL'] ?? 'https://my.transquinnftw.com';
const recording = cfg.recording ?? quinnRecordingProfile();
if (name === cfg.devicesTool.name) {
const res = await run(adb, ['devices'], process.env, 15_000);
@ -146,18 +201,18 @@ async function dispatch(cfg: RedroidMcpConfig, name: string, args: Args): Promis
if (name === cfg.lookupTool.name) {
const phone = typeof args['phone'] === 'string' ? args['phone'].trim() : '';
if (phone === '') throw new Error('Missing required: phone');
const clientId = typeof args['client_id'] === 'number' ? args['client_id'] : undefined;
const dryRun = args['dry_run'] === true;
const cmd = ['--json', '--phone', phone];
if (clientId !== undefined) cmd.push('--client-id', String(clientId));
const arg = recording.inputArg;
if (arg) {
const v = args[arg.key];
const present = arg.type === 'number' ? typeof v === 'number' : typeof v === 'string' && v !== '';
if (present) cmd.push(arg.flag, String(v));
}
if (dryRun) cmd.push('--dry-run');
const env: NodeJS.ProcessEnv = {
...process.env,
QUINN_MY_URL: quinnMyUrl,
QUINN_MY_SERVICE_TOKEN: serviceToken(),
};
const env: NodeJS.ProcessEnv = { ...process.env, ...recording.buildEnv() };
// Device lookup sleeps ~9s for paid content + vision; give generous headroom.
const res = await run(python, [cfg.script, ...cmd], env, 120_000);
const out = res.stdout.trim();