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",
|
"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.",
|
"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",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,32 @@ export interface ToolDef {
|
||||||
readonly description: string;
|
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 {
|
export interface RedroidMcpConfig {
|
||||||
/** MCP server name, e.g. `quinn-mr-number`. */
|
/** MCP server name, e.g. `quinn-mr-number`. */
|
||||||
readonly serverName: string;
|
readonly serverName: string;
|
||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
/** Absolute path to the `*_lookup.py` script driven via `--json`. */
|
/** Absolute path to the `*_lookup.py` script driven via `--json`. */
|
||||||
readonly script: string;
|
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;
|
readonly lookupTool: ToolDef;
|
||||||
/** The devices tool (name + description); lists attached adb devices. */
|
/** The devices tool (name + description); lists attached adb devices. */
|
||||||
readonly devicesTool: ToolDef;
|
readonly devicesTool: ToolDef;
|
||||||
|
|
@ -43,8 +62,8 @@ export interface RedroidMcpConfig {
|
||||||
readonly python?: string;
|
readonly python?: string;
|
||||||
/** adb binary (default `$REDROID_ADB` || `adb`). */
|
/** adb binary (default `$REDROID_ADB` || `adb`). */
|
||||||
readonly adb?: string;
|
readonly adb?: string;
|
||||||
/** quinn.my base URL (default `$QUINN_MY_URL` || prod). */
|
/** Recording behaviour (default: the legacy quinn.my profile). */
|
||||||
readonly quinnMyUrl?: string;
|
readonly recording?: RecordingProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpawnResult {
|
interface SpawnResult {
|
||||||
|
|
@ -64,18 +83,55 @@ function makeLogger(serverName: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_FILE = join(homedir(), '.config/quinn-secrets/quinn-my.service-token');
|
/** Resolve a service token: env var wins, else a 0600 plum secret file. */
|
||||||
|
function tokenFromFileOrEnv(envVar: string, file: string): string {
|
||||||
/** Service token for recording: env wins, else the canonical plum secret file. */
|
if (process.env[envVar]) return process.env[envVar] as string;
|
||||||
function serviceToken(): string {
|
|
||||||
if (process.env['QUINN_MY_SERVICE_TOKEN']) return process.env['QUINN_MY_SERVICE_TOKEN'] as string;
|
|
||||||
try {
|
try {
|
||||||
return readFileSync(TOKEN_FILE, 'utf8').trim();
|
return readFileSync(file, 'utf8').trim();
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
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> {
|
function run(bin: string, args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<SpawnResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(bin, args as string[], { env });
|
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>;
|
type Args = Record<string, unknown>;
|
||||||
|
|
||||||
function buildTools(cfg: RedroidMcpConfig) {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
name: cfg.lookupTool.name,
|
name: cfg.lookupTool.name,
|
||||||
description: cfg.lookupTool.description,
|
description: cfg.lookupTool.description,
|
||||||
inputSchema: {
|
inputSchema: { type: 'object' as const, properties, required: ['phone'] },
|
||||||
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'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: cfg.devicesTool.name,
|
name: cfg.devicesTool.name,
|
||||||
|
|
@ -126,7 +181,7 @@ function buildTools(cfg: RedroidMcpConfig) {
|
||||||
async function dispatch(cfg: RedroidMcpConfig, name: string, args: Args): Promise<string> {
|
async function dispatch(cfg: RedroidMcpConfig, name: string, args: Args): Promise<string> {
|
||||||
const adb = cfg.adb ?? process.env['REDROID_ADB'] ?? 'adb';
|
const adb = cfg.adb ?? process.env['REDROID_ADB'] ?? 'adb';
|
||||||
const python = cfg.python ?? process.env['REDROID_PYTHON'] ?? '/opt/homebrew/bin/python3';
|
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) {
|
if (name === cfg.devicesTool.name) {
|
||||||
const res = await run(adb, ['devices'], process.env, 15_000);
|
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) {
|
if (name === cfg.lookupTool.name) {
|
||||||
const phone = typeof args['phone'] === 'string' ? args['phone'].trim() : '';
|
const phone = typeof args['phone'] === 'string' ? args['phone'].trim() : '';
|
||||||
if (phone === '') throw new Error('Missing required: phone');
|
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 dryRun = args['dry_run'] === true;
|
||||||
|
|
||||||
const cmd = ['--json', '--phone', phone];
|
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');
|
if (dryRun) cmd.push('--dry-run');
|
||||||
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = { ...process.env, ...recording.buildEnv() };
|
||||||
...process.env,
|
|
||||||
QUINN_MY_URL: quinnMyUrl,
|
|
||||||
QUINN_MY_SERVICE_TOKEN: serviceToken(),
|
|
||||||
};
|
|
||||||
// Device lookup sleeps ~9s for paid content + vision; give generous headroom.
|
// Device lookup sleeps ~9s for paid content + vision; give generous headroom.
|
||||||
const res = await run(python, [cfg.script, ...cmd], env, 120_000);
|
const res = await run(python, [cfg.script, ...cmd], env, 120_000);
|
||||||
const out = res.stdout.trim();
|
const out = res.stdout.trim();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue