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:
parent
1c4eb95632
commit
65aa4b1989
2 changed files with 82 additions and 27 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue