calliope2 / webapp /server.mjs
AlekseyCalvin's picture
Upload 35 files
0d8a2ea verified
Raw
History Blame Contribute Delete
71.1 kB
#!/usr/bin/env node
// Calliope TS — web app server + MCP (Model Context Protocol) server.
// Serves the static front-end from webapp/public and exposes the full analysis
// pipeline as structured JSON at POST /api/analyze, plus MCP endpoints:
// - POST/GET/DELETE /mcp (Streamable HTTP, modern spec, stateless)
// - GET /sse + POST /messages (SSE, legacy compat for Claude Desktop / Cursor)
// - GET /api/mcp/info (discovery)
// All the information the CLI's Reading and Detailed views print (plus the raw
// UDPipe / nounsing-pro substrates) is serialized here so the browser and LLM
// agents can render it interactively.
import * as http from 'node:http';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { analyzeReadingDocument } from '../dist/index.js';
import { analyzeReadingDocumentClio } from '../dist/clio/pipeline.js';
import { createCalliopeEngine } from '../dist/calliope/engine.js';
import { isPunctuation } from '../dist/parser.js';
import { syllabifyWord, syllableVowelLengths, vowelLengthOf } from '../dist/phonological.js';
import { computeCaesurae } from '../dist/caesura.js';
import { buildFabbHalleGridsForPoem } from '../dist/fabbhalle.js';
import { computeBoundaries } from '../dist/calliope/boundaries.js';
import { summarizePoem, analyzePhonopoetics } from '../dist/rhyme.js';
import * as nounsing from 'nounsing-pro';
// MCP SDK
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(__dirname, 'public');
const PORT = Number(process.env.PORT || 4321);
// ─── Serialization helpers ──────────────────────────────────────────────
const normWordForm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/g, '');
/** Non-punctuation, syllable-bearing words across a line's results, in order. */
function collectLineWords(ln) {
const ws = [];
for (const res of ln.results) {
for (const w of res.sentence.words) {
if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0) ws.push(w);
}
}
return ws;
}
/** Orthographic syllable chunks for a token, aligned to its word's syllables. */
function chunkToken(tokenText, word) {
const sylCount = Math.max(1, word.syllables.length);
const chunks = syllabifyWord(
tokenText, sylCount, syllableVowelLengths(word.syllables),
word.morphSuffix, word.morphPrefix,
);
if (chunks.join('') === tokenText) {
return chunks.map((c, i) => ({ text: c, si: Math.min(i, word.syllables.length - 1) }));
}
const lens = chunks.map(c => c.length);
const out = [];
let ci = 0, consumed = 0;
const push = (ch, si) => {
const last = out[out.length - 1];
if (last && last.si === si) last.text += ch;
else out.push({ text: ch, si });
};
for (const ch of tokenText) {
if (ch === '-') { push(ch, Math.min(ci, word.syllables.length - 1)); continue; }
while (ci < lens.length - 1 && consumed >= lens[ci]) { ci++; consumed = 0; }
push(ch, Math.min(ci, word.syllables.length - 1));
consumed++;
}
return out;
}
/** Structural port of projectStressOntoLine(): the verbatim raw line split into segments. */
function buildSegments(rawLine, words) {
const tokenRe = /[A-Za-z\u00C0-\u024F]+(?:['’\-][A-Za-z\u00C0-\u024F]+)*/g;
const segments = [];
let cursor = 0, wi = 0, m;
const pushGap = (text) => { if (text) segments.push({ t: 'gap', text }); };
while ((m = tokenRe.exec(rawLine)) !== null) {
const start = m.index, end = start + m[0].length;
pushGap(rawLine.slice(cursor, start));
cursor = end;
const token = m[0];
const tokNorm = normWordForm(token);
const matches = (w) => {
if (!w) return false;
const wn = normWordForm(w.word);
return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
};
let matched = -1;
if (matches(words[wi])) matched = wi;
else {
for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
if (matches(words[k])) { matched = k; break; }
}
}
if (matched >= 0) {
segments.push({ t: 'word', w: matched, chunks: chunkToken(token, words[matched]) });
wi = matched + 1;
} else {
pushGap(token);
}
}
pushGap(rawLine.slice(cursor));
return segments;
}
/** Fine-grained syllable phonology derived from the ARPAbet transcription */
const ARPA_VOWEL = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
const ARPA_DIPH = new Set(['AY', 'AW', 'OY', 'EY', 'OW']);
function sylPhonology(phonesStr) {
const toks = String(phonesStr ?? '').replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
const vIdx = toks.findIndex(t => ARPA_VOWEL.test(t));
if (vIdx < 0) return null;
const onsetN = vIdx;
const codaN = toks.length - vIdx - 1;
const vowel = toks[vIdx].replace(/\d/g, '');
const isDiph = ARPA_DIPH.has(vowel);
let long = isDiph;
try { if (vowelLengthOf(phonesStr) === 'long') long = true; } catch { }
const rime = codaN === 0 ? (long ? '-VV' : '-V')
: long ? (codaN >= 2 ? '-TCC' : '-TC')
: (codaN >= 2 ? '-LCC' : '-LC');
return {
onset: onsetN === 0 ? '0' : 'C'.repeat(Math.min(3, onsetN)),
nucleus: isDiph ? 'diphthong' : 'monophthong',
vlen: long ? 'long' : 'short',
codaC: codaN,
open: codaN === 0,
rime,
};
}
const SYL_POSITION = ['final', 'penult', 'antepenult', 'preantepenult'];
function serializeWord(w, idx, firstSyl) {
const dep = w.dependency;
const display = w.displayWord ?? w.word;
const orth = new Array(w.syllables.length).fill('');
try {
for (const c of chunkToken(display, w)) orth[c.si] = (orth[c.si] ?? '') + c.text;
} catch { }
return {
i: idx,
text: w.displayWord ?? w.word,
norm: w.word,
pos: w.lexicalClass,
isContent: !!w.isContent,
phraseStress: w.phraseStress ?? 0,
feats: w.featsMap && Object.keys(w.featsMap).length ? w.featsMap : null,
canonicalRel: w.canonicalRel ?? null,
dep: dep ? {
rel: dep.dependentType,
isRoot: dep.governorIndex === 0 || dep.dependentType === 'root',
govWord: dep.governor?.word ?? null,
govIndex: dep.governorIndex,
} : null,
flags: {
person: !!w.isPersonName,
place: !!w.isPlaceName,
given: !!w.discourseGiven,
coordGiven: !!w.coordinateGiven,
},
morph: (w.morphPrefix || w.morphSuffix)
? { prefix: w.morphPrefix ?? null, suffix: w.morphSuffix ?? null } : null,
firstSyl,
syls: w.syllables.map((s, si) => ({
si,
text: orth[si] || s.text,
phones: s.phones,
weight: s.weight ?? null,
lex: s.lexicalStress ?? s.stress,
boosted: s.stress,
rel: s.relativeStress ?? 'w',
extrametrical: s.extrametrical ?? null,
pos: SYL_POSITION[Math.min(3, w.syllables.length - 1 - si)],
ph: sylPhonology(s.phones),
})),
};
}
function hierarchyMembership(ius, words) {
const loc = new Map();
ius.forEach((iu, ii) =>
iu.phonologicalPhrases.forEach((pp, pi) =>
pp.cliticGroups.forEach((cg, ci) =>
cg.tokens.forEach(t => loc.set(t, { iu: ii, pp: pi, cp: ci })))));
return words.map(w => loc.get(w) ?? null);
}
function serializeHierarchy(ius, words) {
const indexOf = new Map(words.map((w, i) => [w, i]));
return ius.map(iu => ({
pps: iu.phonologicalPhrases.map(pp => ({
cps: pp.cliticGroups.map(cg =>
cg.tokens.map(t => indexOf.get(t)).filter(i => i !== undefined)),
})),
}));
}
function serializeFeet(scansion, totalSyls) {
const isSylChar = (ch) => 'xXwWnNmMsS'.includes(ch);
const feet = [];
let sylIdx = 0;
for (const rawFoot of scansion.split('|')) {
const cells = [];
for (const ch of rawFoot) {
if (ch === '-') { cells.push({ silent: true }); continue; }
if (!isSylChar(ch)) continue;
if (sylIdx < totalSyls) cells.push({ s: sylIdx++, mark: ch.toLowerCase() });
}
feet.push({ pattern: rawFoot, cells });
}
return feet;
}
function serializeDetail(d) {
if (!d) return null;
return {
all: d.all,
keyStresses: d.keyStresses,
meter: d.meter,
meterName: d.meterName,
footCount: d.footCount,
scansion: d.scansion,
certainty: d.certainty,
weightScore: d.weightScore,
maxPossibleWeight: d.maxPossibleWeight,
summary: d.summary,
scoringMode: d.scoringMode ?? null,
...(d.prosodicCrosscheck ? { prosodicCrosscheck: d.prosodicCrosscheck } : {}),
scoringDiagnostics: d.scoringDiagnostics ? {
mode: d.scoringDiagnostics.mode,
method: d.scoringDiagnostics.method,
edgeEnding: d.scoringDiagnostics.edgeEnding ?? 'none',
edgeTailPositions: d.scoringDiagnostics.edgeTailPositions ?? [],
samples: (d.scoringDiagnostics.samples ?? []).map(sample => ({
...sample,
boundaryStrength: Number(sample.boundaryStrength.toFixed(3)),
})),
candidates: (d.scoringDiagnostics.candidates ?? []).map(candidate => ({
...candidate,
score: Number(candidate.score.toFixed(4)),
fit: Number(candidate.fit.toFixed(4)),
clean: Number(candidate.clean.toFixed(4)),
unitEvidence: Number(candidate.unitEvidence.toFixed(4)),
onset: Number(candidate.onset.toFixed(4)),
prior: Number(candidate.prior.toFixed(4)),
})),
} : null,
ranking: (d.ranking ?? []).map(r => ({ meter: r.meter, score: Number(r.score.toFixed(3)) })),
standaloneMeter: d.standaloneMeter ?? null,
consensusMeter: d.consensusMeter ?? null,
rhythmNote: d.rhythmNote ?? null,
metricalityNote: d.metricalityNote ?? null,
formNote: d.formNote ?? null,
rhyme: d.rhyme ? {
endWord: d.rhyme.endWord,
letter: d.rhyme.letter,
type: d.rhyme.type ?? null,
matchedLine: d.rhyme.matchedLine ?? null,
notation: d.rhyme.notation ?? null,
internal: (d.rhyme.internal ?? []).map(iw => ({
word: iw.word, letter: iw.letter, type: iw.type ?? null,
})),
} : null,
};
}
function serializeLine(ln) {
const res = ln.results[0];
if (!res) return { raw: ln.raw, parsed: false };
const words = collectLineWords(ln);
let firstSyl = 0;
const serWords = words.map((w, i) => {
const sw = serializeWord(w, i, firstSyl);
firstSyl += w.syllables.length;
return sw;
});
const totalSyls = firstSyl;
const syllables = [];
serWords.forEach(sw => sw.syls.forEach(s => syllables.push({
w: sw.i, si: s.si, text: s.text, phones: s.phones, weight: s.weight,
lex: s.lex, rel: s.rel,
})));
const ius = res.phonologicalHierarchy;
const allWords = res.sentence.words;
let boundaries = null;
try {
const b = computeBoundaries(allWords, ius);
boundaries = {
phi: b.phi.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
iota: b.iota.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
};
} catch { }
let caesurae = [];
try {
caesurae = [...computeCaesurae(allWords, ius, res.phonologicalScansion.scansion)]
.map(([after, info]) => ({ after, kind: info.kind, strength: info.strength }));
} catch { }
const deps = serWords
.filter(sw => sw.dep)
.map(sw => {
let govIdx = -1;
try {
const gov = sw.dep.isRoot ? null : words[sw.i].dependency?.governor;
if (gov) govIdx = words.findIndex(w => w === gov);
} catch {}
return { from: sw.i, to: sw.dep.isRoot ? -1 : govIdx, rel: sw.dep.rel };
});
return {
raw: ln.raw,
parsed: true,
focusPrimacyMode: res.sentence.focusPrimacyMode ?? null,
segments: buildSegments(ln.raw, words),
words: serWords,
syllables,
hierarchy: serializeHierarchy(ius, words),
unitOf: hierarchyMembership(ius, words),
boundaries,
caesurae,
keyStresses: (res.keyStresses ?? []).map(k => ({
unitType: k.unitType, pattern: k.pattern, weight: k.weight, positions: k.positions,
})),
feet: serializeFeet(res.phonologicalScansion.scansion, totalSyls),
detail: serializeDetail(res.phonologicalScansion),
scandroid: (() => {
const sn = res.scandroidNative;
if (!sn) return null;
const serFoot = f => f ? {
algorithm: f.algorithm, scanString: f.scanString, marksString: f.marksString,
substitutions: f.substitutions, ok: f.ok, failReason: f.failReason ?? null,
} : null;
return {
metronName: sn.metronName,
lineLengthName: sn.lineLengthName,
lineFeetSet: sn.lineFeetSet,
verdict: serFoot(sn.verdict),
corralTheWeird: serFoot(sn.corralTheWeird ?? null),
maximizeTheNormal: serFoot(sn.maximizeTheNormal ?? null),
promotions: sn.promotions,
};
})(),
fabbHalle: null,
deps,
};
}
function attachFabbHalle(resultStanzas) {
try {
const lines = resultStanzas.flatMap(st => st.lines).filter(l => l.parsed);
const allFhSyls = lines.map(line => {
const perWord = new Map();
line.syllables.forEach(s => perWord.set(s.w, (perWord.get(s.w) ?? 0) + 1));
return line.syllables.map(s => {
const poly = (perWord.get(s.w) ?? 1) > 1;
const cliticMono = !poly && line.words[s.w] && !line.words[s.w].isContent;
return { text: s.text, lex: cliticMono ? 0 : (s.lex ?? 0), poly };
});
});
const poem = buildFabbHalleGridsForPoem(allFhSyls);
if (!poem) return null;
lines.forEach((line, i) => {
const fh = poem.grids[i];
const engineMeter = line.detail?.meter ?? null;
const agrees = engineMeter
? (engineMeter.includes(poem.schema)
|| (poem.schema === 'loose' && /accentual|loose|dolnik|free/i.test(engineMeter)))
: null;
line.fabbHalle = fh ? {
rule: fh.ruleLabel
+ ' · poem-discovered by grid construction'
+ (agrees === null ? '' : agrees ? ', agreeing with the engine' : ` (the engine read ${engineMeter})`),
rows: fh.rows,
maxima: fh.maxima, violations: fh.violations, metrical: fh.metrical,
looseFeet: fh.looseFeet ?? null,
schema: poem.schema,
} : null;
});
return poem.schema;
} catch { return null; }
}
function lineRunsOn(raw) {
const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
if (!t) return false;
return !/[.!?;:,—–…]$/.test(t);
}
function summariseEnjambment(stanzas) {
const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
if (raws.length < 2) return null;
const interior = raws.slice(0, -1);
const enjambed = [];
interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
const n = interior.length, k = enjambed.length;
if (k === 0) return 'end-stopped throughout';
const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
return k >= Math.ceil(n / 2)
? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
: `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
}
function analyze(
text,
engine,
prosody = 'wagner',
useScandroid = true,
scoringMode = 'current',
prosodicCrosscheck = 'off',
) {
const t0 = Date.now();
const focusPrimacyMode = prosody === 'spe' ? 'spe' : 'wagner';
const selectedScoringMode = scoringMode === 'mcaleese' ? 'mcaleese' : 'current';
const selectedCrosscheck = prosodicCrosscheck === 'rerank'
? 'adaptive'
: prosodicCrosscheck === 'advisory' || prosodicCrosscheck === 'adaptive'
? prosodicCrosscheck
: 'off';
const stanzas = engine === 'clio'
? analyzeReadingDocumentClio(text)
: analyzeReadingDocument(
text,
createCalliopeEngine(focusPrimacyMode),
useScandroid,
selectedScoringMode,
selectedCrosscheck,
);
const resultStanzas = stanzas.map(st => {
const firstDetail = st.lines.flatMap(l => l.results)[0]?.phonologicalScansion;
return {
formNote: firstDetail?.formNote ?? null,
lines: st.lines.map(serializeLine),
};
});
const fabbHalleMeter = attachFabbHalle(resultStanzas);
const grouped = stanzas.map(st => st.lines.flatMap(l => l.results));
let synopsis = [];
let phonopoetics = null;
try { synopsis = summarizePoem(grouped); } catch { }
try {
const p = analyzePhonopoetics(grouped);
phonopoetics = {
endScheme: p.endScheme,
end: p.end, caesural: p.caesural, head: p.head,
alliteration: p.alliteration, acrostics: p.acrostics,
};
} catch { }
return {
engine: engine === 'clio' ? 'clio' : 'calliope',
focusPrimacyMode: engine === 'clio' ? null : focusPrimacyMode,
scoringMode: engine === 'clio' ? null : selectedScoringMode,
...(engine !== 'clio' && selectedCrosscheck !== 'off'
? { prosodicCrosscheck: selectedCrosscheck }
: {}),
elapsedMs: Date.now() - t0,
stanzas: resultStanzas,
synopsis,
enjambment: summariseEnjambment(stanzas),
phonopoetics,
fabbHalleMeter,
};
}
function wordDossier(raw) {
const word = String(raw).toLowerCase().replace(/[^a-z'’\-]/g, '').replace(/’/g, "'");
const first = (fn) => { try { const r = fn(word); return Array.isArray(r) ? r[0] ?? null : r ?? null; } catch { return null; } };
const insetsRaw = first(nounsing.metricalInsets);
const insets = insetsRaw
? Object.fromEntries(Object.entries(insetsRaw).filter(([, v]) => v?.length))
: null;
let rhymes = [];
try { rhymes = nounsing.rhymes(word).slice(0, 18); } catch { }
return {
word,
lexicon: first(nounsing.lexicon),
phonemics: first(nounsing.phonemics),
scansion: first(nounsing.scansion),
weights: first(nounsing.weights),
vowels: first(nounsing.vowels),
vowelQualities: first(nounsing.vowelQualities),
edges: first(nounsing.edges),
onsetParse: first(nounsing.onsetParse),
morphology: first(nounsing.morphology),
suffixShift: first(nounsing.suffixShiftPotential),
extrametricals: first(nounsing.extrametricals),
rhymeProfile: first(nounsing.rhymeProfile),
codaComplexity: first(nounsing.codaComplexity),
insets,
rhymes,
};
}
// ─── MCP Server Factory ───────────────────────────────────────────────
const sseTransports = new Map(); // sessionId -> SSEServerTransport
const httpTransports = new Map(); // sessionId -> StreamableHTTPServerTransport + server instance
function createMcpServer() {
const server = new McpServer({
name: 'calliope-ts',
version: '0.1.7',
}, {
capabilities: {
tools: {},
}
});
// Capabilities / help tool
server.registerTool('get_capabilities', {
title: 'Get Calliope TS Capabilities',
description: 'Return high-level description of Calliope TS: engines, meters, rhyme types, syntax, rewrites, and example queries. Use to discover how to call other tools.',
inputSchema: {},
}, async () => {
const caps = {
name: 'calliope-ts',
version: '0.1.7',
description: 'Phonological poetry scansion & analysis suite in TypeScript. English + Russian, with Fabb-Halle grids, Scandroid, syntax, rhyme, rewrites.',
engines: {
calliope: 'Default faithful English engine (McAleese lineage, UDPipe, DepEdit, Nounsing Pro)',
clio: 'Alternative fixed parse for comparison',
russian: 'Russian poetry scansion (UDPipe SynTagRus + neural stress + dictionary, Koziev/Gasparov lineage)',
auto: 'Auto-detect Cyrillic vs Latin, route to Russian or Calliope',
},
focus_primacy_modes: {
wagner: 'Default Calliope mode: Wagner-style relational matching and prosodic subordination',
spe: 'Halle/Chomsky mode: strict cyclic Compound + Nuclear Stress Rules (SPE numbering, 1 strongest)',
},
meter_scoring_modes: {
current: 'Default gradient DP plus the established PP/IU right-edge bonus',
mcaleese: 'Alternate McAleese-informed allocated PW/CP/PP/IU anchors and line-edge evidence over the same DP fits',
},
prosodic_crosscheck_modes: {
off: 'Default: exact existing pipeline, no tableau report',
advisory: 'Attach an independent Calliope-feature OT/HG tableau and exact Pareto-frontier report without changing the verdict',
adaptive: 'Late arbitration when independent local and document grids agree and a low-repair Calliope realization clears held-out activation gates',
rerank: 'Deprecated compatibility alias for adaptive arbitration',
},
meters: ['iambic', 'trochaic', 'anapestic', 'dactylic', 'amphibrachic', 'dolnik', 'accentual', 'free verse', 'loose iambic (Fabb-Halle)'],
instruments: ['Scansion (full reading view)', 'Rhyme Forge (word dossiers)', 'Rewrites (Nounsing Pro transforms)', 'Syntax (UDPipe arcs)', 'Fabb-Halle bracketed grids (independent second opinion)', 'Scandroid (Hartman) second opinion'],
tools: [
{ name: 'scan_poem', desc: 'Full poem scan for meter, rhyme, form, enjambment, phonopoetics' },
{ name: 'scan_line', desc: 'Deep single-line close reading' },
{ name: 'parse_syntax', desc: 'Dependency syntax via UDPipe' },
{ name: 'get_word_dossier', desc: 'Deep Nounsing Pro dossier for a word' },
{ name: 'find_rhymes', desc: 'Twenty-one rhyme types + POS/frequency/syllable/meter/stress filters' },
{ name: 'meter_match', desc: 'Find words matching stress pattern (e.g. 0101)' },
{ name: 'rewrite_text', desc: 'Stress/rhyme/phone-preserving rewrites' },
{ name: 'analyze_russian_poem', desc: 'Dedicated Russian pipeline' },
{ name: 'get_capabilities', desc: 'This help' },
],
endpoints: {
rest: {
'/api/analyze': 'POST {text, engine, prosody, scoringMode, prosodicCrosscheck, useScandroid} -> full JSON',
'/api/russian': 'POST {text} -> Russian',
'/api/word?w=...': 'GET word dossier',
'/api/meter?pattern=010': 'GET meter match',
'/api/rewrite': 'POST {text, mode, ...} -> rewritten',
},
mcp: {
streamableHttp: '/mcp (POST for JSON-RPC, GET for SSE notifications) - modern spec',
sse: '/sse (GET establishes SSE, returns endpoint event)',
messages: '/messages?sessionId=... (POST JSON-RPC for SSE transport)',
info: '/api/mcp/info',
}
},
example_queries: {
scan_poem: { text: 'Shall I compare thee to a summer’s day?\nThou art more lovely and more temperate:', engine: 'auto', prosody: 'wagner', scoring_mode: 'current', prosodic_crosscheck: 'advisory', detail_level: 'summary' },
scan_line: { text: 'Mary ate sweet ice cream.', engine: 'calliope', prosody: 'spe', scoring_mode: 'mcaleese', prosodic_crosscheck: 'off' },
get_word_dossier: { word: 'summer', rhyme_by_syllable: 2 },
find_rhymes: { word: 'day', rhyme_type: 'perfect', syllable_count: 1, limit: 20 },
meter_match: { pattern: '0101010101', limit: 20 },
rewrite_text: { text: 'Shall I compare thee to a summer’s day?', mode: 'stress', language: 'en', freq_threshold: 2.25 },
},
usage_for_agents: 'For remote OpenRouter / Claude / Cursor agents: point MCP client at https://<your-space>.hf.space/mcp (streamable HTTP) or https://<your-space>.hf.space/sse (SSE). No auth needed for public Spaces. Use scan_poem for poems/songs/stanzas/lines, parse_syntax for syntax, find_rhymes / meter_match for composition, rewrite_text for transforms.',
};
return { content: [{ type: 'text', text: JSON.stringify(caps, null, 2) }] };
});
// scan_poem
server.registerTool('scan_poem', {
title: 'Scan Poem / Verse / Stanza / Song',
description: 'Full phonological scansion of a poem, song lyric, stanza, verse, or any multi-line text. Supports English and Russian (auto-detected). Returns meter, rhyme scheme, form, synopsis, per-line tags, Fabb-Halle independent grids, Scandroid second opinion, enjambment, phonopoetics. Use detail_level summary for LLM-friendly compact output, full for exhaustive syllable/word data.',
inputSchema: {
text: z.string().min(1).max(10000).describe('Poem text, single line to full poem. Newlines and blank lines delimit lines and stanzas. Up to 200 non-empty lines, 10k chars.'),
engine: z.enum(['auto', 'calliope', 'clio', 'russian']).optional().default('auto').describe('Engine: auto detects Cyrillic vs Latin, calliope default English, clio alternative, russian forces Russian.'),
prosody: z.enum(['wagner', 'spe']).optional().default('wagner').describe('Calliope Focus Primacy mode: wagner=relational matching/subordination (default); spe=strict Halle/Chomsky cyclic Compound + Nuclear Stress Rules. Ignored by Clio/Russian.'),
scoring_mode: z.enum(['current', 'mcaleese']).optional().default('current').describe('English Calliope meter scorer: current=default gradient/edge scorer; mcaleese=alternate allocated PW/CP/PP/IU anchor and edge-treatment scorer. Ignored by Clio/Russian.'),
prosodic_crosscheck: z.enum(['off', 'advisory', 'adaptive', 'rerank']).optional().default('off').describe('Late Calliope-native alternating-position OT/HG tableau: off=default exact compatibility; advisory=diagnostics only; adaptive=held-out-calibrated local-plus-document arbitration; rerank=legacy alias. Ignored by Clio/Russian.'),
use_scandroid: z.boolean().optional().default(true).describe('Include Hartman Scandroid as an independent second opinion. Calliope only; disabling saves work.'),
detail_level: z.enum(['summary', 'full']).optional().default('summary').describe('summary=compact LLM-friendly (meter, rhyme, synopsis, per-line). full=exhaustive JSON with syllables, stress, deps, feet, hierarchy.'),
}
}, async ({ text, engine, prosody, scoring_mode, prosodic_crosscheck, use_scandroid, detail_level }) => {
try {
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
const isRu = cyr > lat && cyr > 0;
const eng = engine ?? 'auto';
let result;
if (eng === 'russian' || (eng === 'auto' && isRu)) {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const ru = await analyzeRussianPoem(text);
result = { engine: 'russian', elapsedMs: Date.now() - t0, ...ru };
} else {
const useEng = eng === 'auto' ? 'calliope' : eng;
result = analyze(text, useEng, prosody, use_scandroid, scoring_mode, prosodic_crosscheck);
}
if (detail_level === 'full') {
const str = JSON.stringify(result, null, 2);
if (str.length > 200000) {
return { content: [{ type: 'text', text: str.slice(0, 200000) + '\n...[truncated at 200k, request summary or split poem]' }] };
}
return { content: [{ type: 'text', text: str }] };
}
// summary
const compact = {
engine: result.engine,
focusPrimacyMode: result.focusPrimacyMode ?? null,
scoringMode: result.scoringMode ?? null,
...(result.prosodicCrosscheck ? { prosodicCrosscheck: result.prosodicCrosscheck } : {}),
elapsedMs: result.elapsedMs,
meter_overall: result.meter ?? result.meterRu ?? result.meter?.meterRu ?? null,
score: result.score ?? null,
rhymeScheme_overall: result.rhymeScheme ?? result.phonopoetics?.endScheme ?? null,
synopsis: result.synopsis ?? null,
enjambment: result.enjambment ?? null,
fabbHalleMeter: result.fabbHalleMeter ?? null,
formNote: result.stanzas?.[0]?.formNote ?? null,
stanzas: (result.stanzas || []).map((st, si) => ({
index: si,
formNote: st.formNote ?? null,
lines: (st.lines || []).map(l => ({
raw: l.raw,
meter: l.meterRu ?? l.detail?.meter ?? null,
meterEn: l.meterEn ?? l.detail?.meterName ?? null,
meterCertainty: l.detail?.certainty ?? null,
scoringMode: l.detail?.scoringMode ?? null,
...(l.detail?.prosodicCrosscheck ? { prosodicCrosscheck: {
mode: l.detail.prosodicCrosscheck.mode,
status: l.detail.prosodicCrosscheck.status,
reason: l.detail.prosodicCrosscheck.reason,
applied: l.detail.prosodicCrosscheck.applied,
independentMeter: l.detail.prosodicCrosscheck.independentMeter ?? null,
independentConfidence: l.detail.prosodicCrosscheck.independentConfidence ?? null,
documentMeter: l.detail.prosodicCrosscheck.documentMeter ?? null,
documentConfidence: l.detail.prosodicCrosscheck.documentConfidence ?? null,
documentVotes: l.detail.prosodicCrosscheck.documentVotes ?? null,
documentEvaluatedLines: l.detail.prosodicCrosscheck.documentEvaluatedLines ?? null,
documentVoteShare: l.detail.prosodicCrosscheck.documentVoteShare ?? null,
documentVoteMargin: l.detail.prosodicCrosscheck.documentVoteMargin ?? null,
recommendedMeter: l.detail.prosodicCrosscheck.recommendedMeter ?? null,
candidateCount: l.detail.prosodicCrosscheck.candidateCount,
frontierSize: l.detail.prosodicCrosscheck.frontierSize,
} } : {}),
summary: l.detail?.summary ?? l.note ?? null,
scansion: l.detail?.scansion ?? l.scansion ?? null,
syllableCount: l.syllableCount ?? l.syllables?.length ?? null,
rhyme: l.rhyme ?? l.detail?.rhyme ?? null,
rhymeForm: l.rhyme ? `${l.rhyme.letter ?? '-'}${l.rhyme.type ? `(${l.rhyme.type})` : ''}` : null,
fabbHalle: l.fabbHalle ? { rule: l.fabbHalle.rule, metrical: l.fabbHalle.metrical, violations: l.fabbHalle.violations?.length ?? 0, maxima: l.fabbHalle.maxima } : null,
scandroid: l.scandroid ? { metron: l.scandroid.metronName, lineLen: l.scandroid.lineLengthName, scan: l.scandroid.verdict?.scanString } : null,
}))
}))
};
return { content: [{ type: 'text', text: JSON.stringify(compact, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `scan_poem failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// scan_line
server.registerTool('scan_line', {
title: 'Scan Single Verse Line (Deep)',
description: 'Close reading of a single verse line: syllable-by-syllable stress (lexical, phrase, relative), weight, POS, feats, dependency, feet, caesurae, prosodic hierarchy (IU/PP/CP), Scandroid second opinion, Fabb-Halle grid. Best for line-level analysis.',
inputSchema: {
text: z.string().min(1).max(1000).describe('Single verse line text'),
engine: z.enum(['auto', 'calliope', 'clio', 'russian']).optional().default('auto').describe('Engine selection'),
prosody: z.enum(['wagner', 'spe']).optional().default('wagner').describe('Calliope Focus Primacy mode; ignored by Clio/Russian'),
scoring_mode: z.enum(['current', 'mcaleese']).optional().default('current').describe('Calliope English meter-scoring mode; ignored by Clio/Russian'),
prosodic_crosscheck: z.enum(['off', 'advisory', 'adaptive', 'rerank']).optional().default('off').describe('Independent late tableau mode; adaptive may apply held-out-calibrated local-plus-document arbitration; rerank is a legacy alias'),
use_scandroid: z.boolean().optional().default(true).describe('Include Hartman Scandroid comparison (Calliope only)'),
}
}, async ({ text, engine, prosody, scoring_mode, prosodic_crosscheck, use_scandroid }) => {
try {
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
const isRu = cyr > lat && cyr > 0;
const eng = engine ?? 'auto';
if (eng === 'russian' || (eng === 'auto' && isRu)) {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const ru = await analyzeRussianPoem(text);
return { content: [{ type: 'text', text: JSON.stringify(ru, null, 2) }] };
}
const useEng = eng === 'auto' ? 'calliope' : eng;
const res = analyze(text, useEng, prosody, use_scandroid, scoring_mode, prosodic_crosscheck);
const firstLine = res.stanzas?.[0]?.lines?.[0];
if (!firstLine) return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] };
return { content: [{ type: 'text', text: JSON.stringify(firstLine, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `scan_line failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// parse_syntax
server.registerTool('parse_syntax', {
title: 'Parse Syntax (UDPipe + DepEdit)',
description: 'Parse syntax via UDPipe (English: SynTagRus for Russian auto-route) with DepEdit-TS repairs. Returns tokens with UPOS, features, dependency relations, governors, and prosodic hierarchy IU.PP.CP. Works for poem, stanza, verse, or single line. Language auto-detected.',
inputSchema: {
text: z.string().min(1).max(10000).describe('Text to parse (poem, stanza, line, sentence)'),
engine: z.enum(['auto', 'calliope', 'clio', 'russian']).optional().default('auto').describe('Engine, auto detects Russian'),
prosody: z.enum(['wagner', 'spe']).optional().default('wagner').describe('Calliope Focus Primacy mode; ignored by Clio/Russian'),
fuse_lines: z.boolean().optional().default(false).describe('Replace line breaks with spaces and parse the input as one continuous unit'),
}
}, async ({ text, engine, prosody, fuse_lines }) => {
try {
const parseText = fuse_lines ? text.replace(/\s*\n+\s*/g, ' ') : text;
const cyr = (parseText.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (parseText.match(/[A-Za-z]/g) || []).length;
const isRu = cyr > lat && cyr > 0;
const eng = engine ?? 'auto';
if (eng === 'russian' || (eng === 'auto' && isRu)) {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const ru = await analyzeRussianPoem(parseText);
// Extract deps from ru stanzas
const deps = (ru.stanzas || []).map((st, si) => ({
stanza: si,
lines: (st.lines || []).map(l => ({
raw: l.raw,
words: (l.words || []).map(w => ({
text: w.form ?? w.text,
lemma: w.lemma,
upos: w.upos,
feats: w.feats,
deprel: w.deprel ?? w.dep?.rel,
head: w.head,
syllables: w.syllables,
}))
}))
}));
return { content: [{ type: 'text', text: JSON.stringify({ engine: 'russian', deps }, null, 2) }] };
}
const useEng = eng === 'auto' ? 'calliope' : eng;
const res = analyze(parseText, useEng, prosody, false);
const syntax = res.stanzas.map((st, si) => ({
stanza: si,
formNote: st.formNote,
lines: st.lines.map(l => ({
raw: l.raw,
words: l.words.map(w => ({
text: w.text,
norm: w.norm,
pos: w.pos,
isContent: w.isContent,
feats: w.feats,
dep: w.dep,
flags: w.flags,
firstSyl: w.firstSyl,
})),
deps: l.deps,
hierarchy: l.hierarchy,
boundaries: l.boundaries,
caesurae: l.caesurae,
}))
}));
return { content: [{ type: 'text', text: JSON.stringify({ engine: useEng, focusPrimacyMode: res.focusPrimacyMode, fuse_lines, syntax }, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `parse_syntax failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// get_word_dossier
server.registerTool('get_word_dossier', {
title: 'Get Word Dossier (Nounsing Pro)',
description: 'Deep phonological, morphological, and metrical dossier for a single English word via Nounsing Pro augmented CMU lexicon: phones, syllabification, stress contour, weight pattern (H/L), rhyme profile, onset/coda geometry, morphology, lexical frequency, metrical insets, perfect rhymes. For Russian, use scan_line or analyze_russian_poem.',
inputSchema: {
word: z.string().min(1).max(100).describe('English word to look up'),
rhyme_by_syllable: z.number().int().min(1).max(8).optional().describe('Optional filter: return rhymes with exactly N syllables (1-8)'),
}
}, async ({ word, rhyme_by_syllable }) => {
try {
const dossier = wordDossier(word);
if (rhyme_by_syllable) {
try { dossier.rhymesBySyll = nounsing.rhymeBySyllables(dossier.word, rhyme_by_syllable).slice(0, 60); }
catch { dossier.rhymesBySyll = []; }
}
return { content: [{ type: 'text', text: JSON.stringify(dossier, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `get_word_dossier failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// find_rhymes
server.registerTool('find_rhymes', {
title: 'Find Rhymes',
description: 'Find one of Nounsing Pro’s documented rhyme types with optional pronunciation, POS, frequency, syllable, poetic-foot, and stress-contour filters.',
inputSchema: {
word: z.string().min(1).max(100).describe('Word to rhyme'),
rhyme_type: z.enum(['perfect', 'family', 'slant', 'masculine', 'feminine', 'dactylic', 'eye', 'rich', 'assonant', 'consonant', 'augmented', 'diminished', 'syllabic', 'light', 'wrenched', 'grammatical', 'trailing', 'apocopated', 'unstressed', 'mosaic', 'identical']).optional().default('perfect').describe('Rhyme taxonomy to search; default perfect'),
limit: z.number().int().min(1).max(100).optional().default(20).describe('Max results, default 20, max 100'),
syllable_count: z.number().int().min(1).max(8).optional().describe('Filter rhymes to exact syllable count'),
phones: z.string().max(200).optional().describe('Optional ARPAbet pronunciation override for the source word'),
pos_precision: z.number().int().min(0).max(3).optional().default(0).describe('Part-of-speech fidelity: 0 off, 1 first tag letter, 2 first two letters, 3 exact Penn tag'),
freq_threshold: z.number().min(0).max(10).optional().default(0).describe('Minimum Zipf frequency for candidates'),
poetic_fit: z.enum(['iamb', 'trochee', 'spondee', 'pyrrhic', 'dactyl', 'anapest', 'amphibrach', 'bacchic', 'antibacchic', 'cretic', 'choriamb', 'antispast', 'first paeon', 'second paeon', 'third paeon', 'fourth paeon']).optional().describe('Require candidates to contain this poetic foot'),
stress_pattern: z.string().regex(/^[012]+$/).max(20).optional().describe('Require this exact lexical stress contour: 0 unstressed, 1 primary, 2 secondary'),
}
}, async ({ word, rhyme_type, limit, syllable_count, phones, pos_precision, freq_threshold, poetic_fit, stress_pattern }) => {
try {
const w = String(word).toLowerCase().replace(/[^a-z'’\-]/g, '').replace(/’/g, "'");
let rhymes = [];
try {
rhymes = nounsing.getRhymes(w, rhyme_type ?? 'perfect', {
phones,
posPrecision: pos_precision ?? 0,
freqThreshold: freq_threshold ?? 0,
syllables: syllable_count,
poeticFit: poetic_fit,
stressPattern: stress_pattern,
});
} catch { rhymes = []; }
const total = rhymes.length;
const slice = rhymes.slice(0, limit ?? 20);
return { content: [{ type: 'text', text: JSON.stringify({ word: w, rhyme_type, total, filters: { syllable_count: syllable_count ?? null, pos_precision, freq_threshold, poetic_fit: poetic_fit ?? null, stress_pattern: stress_pattern ?? null }, rhymes: slice }, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `find_rhymes failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// meter_match
server.registerTool('meter_match', {
title: 'Meter Match (Find Words by Stress Pattern)',
description: 'Given a lexical stress contour (0 unstressed, 1 primary, 2 secondary), return English words carrying exactly that pattern. Examples: 01=iamb, 010=amphibrach, 100=dactyl.',
inputSchema: {
pattern: z.string().regex(/^[012]+$/).max(20).describe('Stress pattern, e.g. 010. Digits: 0=unstressed, 1=primary, 2=secondary.'),
limit: z.number().int().min(1).max(200).optional().default(72).describe('Max words to return, default 72'),
}
}, async ({ pattern, limit }) => {
try {
const pat = String(pattern);
if (!/^[012]+$/.test(pat)) throw new Error('Pattern must contain only 0/1/2');
let words = [];
try { words = nounsing.meterMatch(pat); } catch { words = []; }
// deterministic shuffle-free sample: take first N sorted? keep random sample but stable? For MCP we return first 72 unsorted to be deterministic-ish
const total = words.length;
const out = words.slice(0, limit ?? 72);
return { content: [{ type: 'text', text: JSON.stringify({ pattern: pat, total, words: out }, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `meter_match failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// rewrite_text
server.registerTool('rewrite_text', {
title: 'Rewrite Text (Prosody-Preserving Transforms)',
description: 'Creative rewrites preserving prosodic skeleton: stress (same stress contour), rhyme (same rhyme profile), or phones (sound-echo). Controls: pos_precision (0-3 POS fidelity), freq_threshold (0-4 word frequency filter), fuzzy_rhyme (bool), morph_ground (bool morphemic grounding), register_fidelity (0-4), dict_pos (bool use dictionary POS). Works for English and Russian (auto-detected). Max 4000 chars.',
inputSchema: {
text: z.string().min(1).max(4000).describe('Source text to transform (poem, line, etc.)'),
mode: z.enum(['stress', 'rhyme', 'phones']).optional().default('phones').describe('Rewrite mode: stress=keep stress, rhyme=keep rhyme, phones=sound echo'),
language: z.enum(['auto', 'en', 'ru']).optional().default('auto').describe('Language routing: auto detects script, en forces English, ru forces Russian'),
pos_precision: z.number().int().min(0).max(3).optional().default(1).describe('POS precision 0-3, higher=stricter'),
freq_threshold: z.number().min(0).max(4).optional().default(0).describe('Frequency threshold 0-4 (fractional values allowed), higher=common words only'),
fuzzy_rhyme: z.boolean().optional().default(false).describe('Allow fuzzy rhyme tiers; valid only in rhyme mode'),
morph_ground: z.boolean().optional().default(false).describe('Enable morphemic grounding (English only)'),
register_fidelity: z.number().min(0).max(4).optional().default(0).describe('Register fidelity 0-4 (English only)'),
dict_pos: z.boolean().optional().default(false).describe('Use dictionary POS instead of contextual parsing (English only)'),
}
}, async ({ text, mode, language, pos_precision, freq_threshold, fuzzy_rhyme, morph_ground, register_fidelity, dict_pos }) => {
try {
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
const pos = Math.max(0, Math.min(3, Number(pos_precision ?? 1)));
const freq = Math.max(0, Math.min(4, Number(freq_threshold ?? 0)));
const fuzzy = Boolean(fuzzy_rhyme);
if (fuzzy && mode !== 'rhyme') throw new Error('fuzzy_rhyme is only valid when mode="rhyme"');
const useRussian = language === 'ru' || (language !== 'en' && cyr > lat && cyr > 0);
if (useRussian && (morph_ground || Number(register_fidelity ?? 0) > 0 || dict_pos)) {
throw new Error('morph_ground, register_fidelity, and dict_pos are English-only options');
}
let output;
if (useRussian) {
const { rewriteRussianText } = await import('../dist/russian/rewrite.js');
const ruMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
output = await rewriteRussianText(text, ruMode, pos, freq, fuzzy);
} else {
const { rewriteEnglishText } = await import('../dist/rewriteEn.js');
const enMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
output = rewriteEnglishText(text, enMode, pos, freq, { fuzzyRhyme: fuzzy, morphGround: Boolean(morph_ground), registerFidelity: Math.max(0, Math.min(4, Number(register_fidelity ?? 0))), dictPos: Boolean(dict_pos) });
}
return { content: [{ type: 'text', text: JSON.stringify({ input: text, mode, language: useRussian ? 'ru' : 'en', output }, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `rewrite_text failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
// analyze_russian_poem
server.registerTool('analyze_russian_poem', {
title: 'Analyze Russian Poem (Dedicated Pipeline)',
description: 'Dedicated Russian scansion via Rupo+UDPipe+neural accent model: returns canonical meter (ямб, хорей, дактиль, амфибрахий, анапест, dolnik), technicality scores per stanza, rhyme scheme per block (AABB, ABAB, etc.), per-line stress variants, phonetic clausulae, yofication, and Fabb-Halle grids with polysyllabic maxima mode.',
inputSchema: {
text: z.string().min(1).max(10000).describe('Russian poem text, up to 200 lines'),
detail_level: z.enum(['summary', 'full']).optional().default('summary').describe('summary or full'),
}
}, async ({ text, detail_level }) => {
try {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const ru = await analyzeRussianPoem(text);
const result = { engine: 'russian', elapsedMs: Date.now() - t0, ...ru };
if (detail_level === 'full') {
const str = JSON.stringify(result, null, 2);
if (str.length > 200000) return { content: [{ type: 'text', text: str.slice(0, 200000) + '\n...[truncated]' }] };
return { content: [{ type: 'text', text: str }] };
}
const compact = {
engine: 'russian',
elapsedMs: result.elapsedMs,
meter: result.meter,
meterRu: result.meter?.meterRu ?? result.meter,
score: result.score,
rhymeScheme: result.rhymeScheme,
stanzas: (result.stanzas || []).map((st, si) => ({
index: si,
lines: (st.lines || []).map(l => ({
raw: l.raw,
meter: l.meterRu,
meterEn: l.meterEn,
score: l.score,
syllableCount: l.syllableCount,
scansion: l.scansion,
rhyme: l.rhyme,
words: (l.words || []).map(w => ({ text: w.form ?? w.text, upos: w.upos, stressPos: w.stressPos, tier: w.syllables?.map(s => s.tier).join('') })),
fabbHalle: l.fabbHalle ? { rule: l.fabbHalle.ruleLabel, metrical: l.fabbHalle.metrical, violations: l.fabbHalle.violations } : null,
}))
}))
};
return { content: [{ type: 'text', text: JSON.stringify(compact, null, 2) }] };
} catch (err) {
return { content: [{ type: 'text', text: `analyze_russian_poem failed: ${err?.message ?? String(err)}` }], isError: true };
}
});
return server;
}
// ─── HTTP plumbing ──────────────────────────────────────────────────────
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.txt': 'text/plain; charset=utf-8',
};
function serveStatic(req, res) {
let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (urlPath === '/') urlPath = '/index.html';
// Do not serve static for MCP endpoints - handled earlier
if (urlPath.startsWith('/mcp') || urlPath === '/sse' || urlPath.startsWith('/messages')) {
res.writeHead(404); res.end('Not static');
return;
}
const filePath = path.join(PUBLIC_DIR, path.normalize(urlPath));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403); res.end('Forbidden'); return;
}
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] ?? 'application/octet-stream' });
res.end(data);
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', c => {
body += c;
if (body.length > 512 * 1024) { reject(new Error('payload too large')); req.destroy(); }
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function setCorsHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Last-Event-ID, Authorization, mcp-session-id, last-event-id, x-api-key, X-Api-Key');
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, mcp-session-id');
}
const server = http.createServer(async (req, res) => {
const urlObj = new URL(req.url, 'http://x');
const pathname = urlObj.pathname;
// CORS preflight
if (req.method === 'OPTIONS') {
setCorsHeaders(res);
res.writeHead(200, { 'Access-Control-Max-Age': '86400' });
res.end();
return;
}
// ── MCP: Streamable HTTP (modern spec) — supports BOTH stateful and stateless ──
// Stateful: client does initialize -> gets mcp-session-id -> sends it back for tools/list, tools/call (OpenRouter, Claude)
// Stateless: client sends tools/list or tools/call directly without session (curl tests) -> we handle one-off
if (pathname === '/mcp') {
if (req.method === 'GET') {
const accept = req.headers.accept || '';
if (!accept.includes('text/event-stream') && !accept.includes('application/json')) {
setCorsHeaders(res);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
name: 'calliope-ts MCP',
endpoint: '/mcp',
transports: { streamableHttp: '/mcp', sse: '/sse', messages: '/messages?sessionId=...' },
usage: 'POST /mcp with JSON-RPC (MCP spec 2025-03-26). GET /mcp with Accept: text/event-stream opens SSE notifications stream. Supports both stateful (with mcp-session-id) and stateless modes.',
clients: {
claude_desktop: { mcpServers: { 'calliope-ts': { url: 'https://<your-space>.hf.space/mcp', transport: 'http' } } },
openrouter: 'See https://openrouter.ai/docs/features/mcp - point at /mcp',
cursor: { mcpServers: { 'calliope-ts': { url: 'https://<your-space>.hf.space/mcp' } } },
}
}, null, 2));
return;
}
}
try {
setCorsHeaders(res);
const origWriteHead = res.writeHead.bind(res);
res.writeHead = (statusCode, headers) => {
try { setCorsHeaders(res); } catch {}
if (headers) return origWriteHead(statusCode, headers);
return origWriteHead(statusCode);
};
const incomingSessionId = (req.headers['mcp-session-id'] || req.headers['Mcp-Session-Id'] || '').toString().trim();
// If we have an existing session, reuse it (stateful flow)
if (incomingSessionId && httpTransports.has(incomingSessionId)) {
const entry = httpTransports.get(incomingSessionId);
await entry.transport.handleRequest(req, res);
return;
}
// For POST, we need to peek at the body to decide stateless vs stateful
let parsedBody = undefined;
let methodName = undefined;
if (req.method === 'POST') {
// Read body without consuming twice: we read now and pass parsedBody to SDK
const bodyStr = await readBody(req);
try {
parsedBody = JSON.parse(bodyStr);
// Handle batch (array) or single
const first = Array.isArray(parsedBody) ? parsedBody[0] : parsedBody;
methodName = first?.method;
} catch {
// invalid json will be handled by SDK
try { parsedBody = JSON.parse(bodyStr); } catch { parsedBody = undefined; }
}
// If this is NOT an initialize and no sessionId, treat as stateless one-off (curl, simple clients)
const isInit = methodName === 'initialize';
if (!isInit && !incomingSessionId) {
// Stateless handling: fresh transport per request, no session persistence
const mcpServer = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
enableJsonResponse: false,
});
res.on('close', () => {
try { transport.close(); } catch {}
try { mcpServer.close(); } catch {}
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, parsedBody);
return;
}
// Stateful path: initialize or request with sessionId that we haven't seen (new session)
const mcpServer = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
httpTransports.set(sessionId, { transport, server: mcpServer, createdAt: Date.now() });
},
enableJsonResponse: false,
});
transport.onclose = () => {
if (transport.sessionId) httpTransports.delete(transport.sessionId);
};
res.on('close', () => {
if (!transport.sessionId) {
try { transport.close(); } catch {}
try { mcpServer.close(); } catch {}
}
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, parsedBody);
// Cleanup old sessions
if (httpTransports.size > 100) {
const now = Date.now();
for (const [sid, entry] of httpTransports) {
if (now - entry.createdAt > 30 * 60 * 1000) {
try { entry.transport.close(); } catch {}
try { entry.server.close(); } catch {}
httpTransports.delete(sid);
}
}
}
return;
}
// For GET/DELETE without sessionId -> treat as stateless or return error via SDK
// For GET with Accept: text/event-stream and no session, we allow stateless SSE stream (no notifications but valid)
if (req.method === 'GET' || req.method === 'DELETE') {
if (incomingSessionId && !httpTransports.has(incomingSessionId)) {
setCorsHeaders(res);
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `No session for ${incomingSessionId}` }));
return;
}
// No session for GET/DELETE -> stateless: create one-off transport that will return 405 or empty stream as per SDK
const mcpServer = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: false,
});
res.on('close', () => {
try { transport.close(); } catch {}
try { mcpServer.close(); } catch {}
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res);
return;
}
} catch (err) {
console.error('mcp /mcp failed:', err);
if (!res.headersSent) {
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'MCP error: ' + (err?.message ?? String(err)) }));
}
}
return;
}
// ── MCP: SSE (legacy, for Claude Desktop, Cursor, etc.) ────────────
if (pathname === '/sse' && req.method === 'GET') {
try {
setCorsHeaders(res);
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => {
sseTransports.delete(transport.sessionId);
});
const mcpServer = createMcpServer();
await mcpServer.connect(transport);
} catch (err) {
console.error('sse /sse failed:', err);
if (!res.headersSent) {
setCorsHeaders(res);
res.writeHead(500).end('SSE failed: ' + (err?.message ?? String(err)));
}
}
return;
}
if (pathname === '/messages' && req.method === 'POST') {
const sessionId = urlObj.searchParams.get('sessionId');
if (!sessionId) {
setCorsHeaders(res);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing sessionId query param' }));
return;
}
const transport = sseTransports.get(sessionId);
if (!transport) {
setCorsHeaders(res);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `No transport for sessionId ${sessionId}` }));
return;
}
try {
setCorsHeaders(res);
await transport.handlePostMessage(req, res);
} catch (err) {
console.error('sse /messages failed:', err);
if (!res.headersSent) {
res.writeHead(500).end('Message handling failed');
}
}
return;
}
// ── REST: MCP info/discovery ───────────────────────────────────────
if (pathname === '/api/mcp/info' && req.method === 'GET') {
setCorsHeaders(res);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
name: 'calliope-ts',
version: '0.1.4',
description: 'Poetry scansion & analysis MCP server + REST API',
mcp: {
streamableHttp: { endpoint: '/mcp', methods: ['POST', 'GET', 'DELETE'], spec: 'MCP 2025-03-26 Streamable HTTP, stateless', transport: 'http' },
sse: { endpoint: '/sse', method: 'GET', description: 'Legacy SSE transport: GET /sse returns event: endpoint with sessionId, then POST /messages?sessionId=...' },
clients: {
claude_desktop: {
config: {
mcpServers: {
'calliope-ts': {
command: 'npx',
args: ['-y', 'mcp-remote', 'https://<your-space>.hf.space/sse'],
}
}
},
alternative_http: {
mcpServers: {
'calliope-ts': {
url: 'https://<your-space>.hf.space/mcp',
}
}
}
},
cursor: { mcpServers: { 'calliope-ts': { url: 'https://<your-space>.hf.space/mcp' } } },
openrouter: {
docs: 'https://openrouter.ai/docs/features/mcp',
example: {
model: 'anthropic/claude-3.5-sonnet',
tools: [{ type: 'function', function: { name: 'scan_poem' } }],
mcpServers: [{ name: 'calliope-ts', url: 'https://<your-space>.hf.space/mcp' }]
}
},
generic: {
typescript: "import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const transport = new StreamableHTTPClientTransport(new URL('https://<space>.hf.space/mcp')); const client = new Client({name:'test',version:'1.0'}); await client.connect(transport);",
}
},
tools: ['get_capabilities', 'scan_poem', 'scan_line', 'parse_syntax', 'get_word_dossier', 'find_rhymes', 'meter_match', 'rewrite_text', 'analyze_russian_poem'],
},
rest: {
'/api/analyze': 'POST {text, engine, prosody, scoringMode, prosodicCrosscheck, useScandroid}',
'/api/russian': 'POST {text}',
'/api/word': 'GET ?w=word&syll=2',
'/api/meter': 'GET ?pattern=010',
'/api/rewrite': 'POST {text, mode, posPrecision, freqThreshold, fuzzyRhyme, morphGround, registerFidelity, dictPos}',
'/api/health': 'GET',
}
}, null, 2));
return;
}
// ── REST: existing API ─────────────────────────────────────────────
if (req.method === 'POST' && pathname === '/api/analyze') {
try {
setCorsHeaders(res);
const body = await readBody(req);
const { text, engine, prosody, scoringMode, prosodicCrosscheck, useScandroid } = JSON.parse(body || '{}');
if (!text || typeof text !== 'string' || !text.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No text supplied.' }));
return;
}
if (prosody !== undefined && prosody !== 'wagner' && prosody !== 'spe') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'prosody must be "wagner" or "spe".' }));
return;
}
if (useScandroid !== undefined && typeof useScandroid !== 'boolean') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'useScandroid must be a boolean.' }));
return;
}
if (scoringMode !== undefined && scoringMode !== 'current' && scoringMode !== 'mcaleese') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'scoringMode must be "current" or "mcaleese".' }));
return;
}
if (prosodicCrosscheck !== undefined
&& prosodicCrosscheck !== 'off'
&& prosodicCrosscheck !== 'advisory'
&& prosodicCrosscheck !== 'adaptive'
&& prosodicCrosscheck !== 'rerank') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'prosodicCrosscheck must be "off", "advisory", or "adaptive" (legacy alias: "rerank").' }));
return;
}
const lineCount = text.split('\n').filter(l => l.trim()).length;
if (lineCount > 200) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
return;
}
const cyrillic = (text.match(/[А-Яа-яЁё]/g) || []).length;
const latin = (text.match(/[A-Za-z]/g) || []).length;
if (cyrillic > latin && cyrillic > 0 && engine !== 'calliope' && engine !== 'clio') {
try {
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const ruResult = await analyzeRussianPoem(text);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...ruResult }));
return;
} catch (ruErr) {
console.error('russian auto-detect failed:', ruErr);
}
}
const result = analyze(
text,
engine,
prosody,
useScandroid !== false,
scoringMode,
prosodicCrosscheck,
);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
console.error('analyze failed:', err);
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Analysis failed: ' + (err?.message ?? String(err)) }));
}
return;
}
if (req.method === 'GET' && pathname === '/api/word') {
try {
setCorsHeaders(res);
const params = urlObj.searchParams;
const w = params.get('w') ?? '';
if (!w.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'no word' })); return; }
const dossier = wordDossier(w);
const syll = Number(params.get('syll'));
if (syll >= 1 && syll <= 8) {
try { dossier.rhymesBySyll = nounsing.rhymeBySyllables(dossier.word, syll).slice(0, 60); }
catch { dossier.rhymesBySyll = []; }
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(dossier));
} catch (err) {
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'GET' && pathname === '/api/meter') {
try {
setCorsHeaders(res);
const params = urlObj.searchParams;
const pattern = (params.get('pattern') ?? '').replace(/[^012]/g, '');
if (!pattern) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'pattern must be digits 0/1/2, e.g. 010' })); return; }
let words = [];
try { words = nounsing.meterMatch(pattern); } catch { }
for (let i = words.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [words[i], words[j]] = [words[j], words[i]]; }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pattern, total: words.length, words: words.slice(0, 72) }));
} catch (err) {
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'POST' && pathname === '/api/rewrite') {
try {
setCorsHeaders(res);
const body = JSON.parse(await readBody(req) || '{}');
const text = String(body.text ?? '').slice(0, 4000);
if (!text.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No text supplied.' })); return; }
const pos = Math.max(0, Math.min(3, Number(body.posPrecision ?? 1)));
const freq = Math.max(0, Math.min(4, Number(body.freqThreshold ?? 0)));
const mode = body.mode;
const fuzzy = Boolean(body.fuzzyRhyme);
const morphGround = Boolean(body.morphGround);
const registerFidelity = Math.max(0, Math.min(4, Number(body.registerFidelity ?? 0)));
const dictPos = Boolean(body.dictPos);
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
let output;
if (cyr > lat && cyr > 0) {
const { rewriteRussianText } = await import('../dist/russian/rewrite.js');
const ruMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
output = await rewriteRussianText(text, ruMode, pos, freq, fuzzy);
} else {
const { rewriteEnglishText } = await import('../dist/rewriteEn.js');
const enMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
output = rewriteEnglishText(text, enMode, pos, freq,
{ fuzzyRhyme: fuzzy, morphGround, registerFidelity, dictPos });
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ output }));
} catch (err) {
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
}
return;
}
if (req.method === 'POST' && pathname === '/api/russian') {
try {
setCorsHeaders(res);
const body = await readBody(req);
const { text } = JSON.parse(body || '{}');
if (!text || typeof text !== 'string' || !text.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No text supplied.' }));
return;
}
const lineCount = text.split('\n').filter(l => l.trim()).length;
if (lineCount > 200) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
return;
}
const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
const t0 = Date.now();
const result = await analyzeRussianPoem(text);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...result }));
} catch (err) {
console.error('russian analyze failed:', err);
setCorsHeaders(res);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Russian analysis failed: ' + (err?.message ?? String(err)) }));
}
return;
}
if (req.method === 'GET' && pathname === '/api/health') {
setCorsHeaders(res);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mcp: { streamableHttp: '/mcp', sse: '/sse', info: '/api/mcp/info' } }));
return;
}
if (req.method === 'GET' || req.method === 'HEAD') {
if (pathname === '/api/mcp/info' || pathname === '/.well-known/mcp' || pathname === '/.well-known/mcp.json') {
// already handled above for /api/mcp/info, but handle well-known as alias
if (pathname !== '/api/mcp/info') {
setCorsHeaders(res);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ mcp_endpoint: '/mcp', sse_endpoint: '/sse', info: '/api/mcp/info' }));
return;
}
}
serveStatic(req, res);
return;
}
setCorsHeaders(res);
res.writeHead(405); res.end();
});
server.listen(PORT, () => {
console.log(`Calliope web + MCP listening on http://localhost:${PORT}`);
console.log(` REST: /api/analyze, /api/russian, /api/word, /api/meter, /api/rewrite, /api/health`);
console.log(` MCP Streamable HTTP: http://localhost:${PORT}/mcp (POST/GET/DELETE)`);
console.log(` MCP SSE (legacy): http://localhost:${PORT}/sse (GET) + /messages?sessionId= (POST)`);
console.log(` MCP Info: http://localhost:${PORT}/api/mcp/info`);
try { analyze('Shall I compare thee to a summer’s day?', 'calliope'); console.log('pipeline warm'); }
catch (e) { console.error('warm-up failed:', e); }
});