From 442d9fd8906aaefb3c1e1d8e890724fd93a11e32 Mon Sep 17 00:00:00 2001 From: Alejandro Oroncoy Date: Wed, 11 Nov 2026 17:20:03 -0500 Subject: [PATCH] Expose exact APA rule lookup examples and reject unknown selectors --- .agents/skills/apa7-campus/SKILL.md | 16 ++++++ .../academic/apa7-guidance-discovery.ts | 52 ++++++++++++++++++ src/providers/academic/apa7-mcp-tools.ts | 13 +++-- tests/apa7-detail-discovery.test.ts | 55 +++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/providers/academic/apa7-guidance-discovery.ts create mode 100644 tests/apa7-detail-discovery.test.ts diff --git a/.agents/skills/apa7-campus/SKILL.md b/.agents/skills/apa7-campus/SKILL.md index cc180169..c2635018 100644 --- a/.agents/skills/apa7-campus/SKILL.md +++ b/.agents/skills/apa7-campus/SKILL.md @@ -33,6 +33,22 @@ material proporcionado; cargar las reglas no significa haber leído el trabajo. En revisiones UPC, la guía institucional usa `s.f.` sin espacios y `s.f.-a`/`s.f.-b` para distinguir obras sin fecha del mismo autor. +Una consulta con solo `topic` ofrece una vista general y el catálogo, no todas +las reglas detalladas. Sigue `detailLookup.examples` y usa el selector exacto +del tema, uno por llamada. Por ejemplo: + +```json +{"topic":"table-figure","tableFigureRuleId":"figure-notes"} +``` + +El detalle está en `tableFigureRule.rules`; también puedes seleccionar +`figure-components` o `reprinted-adapted-table-figure`. Estos IDs no son +`reviewSectionId` ni `caseId`. Para todas las consideraciones de figuras de la +guía UPC, usa `{"topic":"review","reviewSectionId":"figures"}` y lee +`reviewSkill.considerations.items` y las secciones relacionadas. Son dos vías +distintas: reglas del manual redactadas por Campus y criterios de la guía UPC; +ninguna devuelve una transcripción literal completa del manual. + No hagas el trabajo intelectual del estudiante ni fabriques citas textuales, páginas, DOI, autores o fechas. Puedes explicar, corregir forma y proponer una plantilla con marcadores claros. ## Uso con Blackboard diff --git a/src/providers/academic/apa7-guidance-discovery.ts b/src/providers/academic/apa7-guidance-discovery.ts new file mode 100644 index 00000000..a5e2f1df --- /dev/null +++ b/src/providers/academic/apa7-guidance-discovery.ts @@ -0,0 +1,52 @@ +export const APA7_GUIDANCE_DESCRIPTION = 'Get Spanish APA 7 guidance and templates. A call with only topic returns an overview and a catalogue, not the detailed rules. Follow detailLookup.examples using the exact selector for that topic. For figure notes call {"topic":"table-figure","tableFigureRuleId":"figure-notes"}; details are in tableFigureRule.rules. For the full UPC figure section call {"topic":"review","reviewSectionId":"figures"}; read reviewSkill.considerations.items and related sections. Review guidance is instructions for the client agent, not a verdict on a document. Does not require Blackboard login; host account authorization still applies.'; + +export const APA7_TABLE_FIGURE_SELECTOR_DESCRIPTION = 'Only with topic="table-figure": select an id from availableTableFigureRules, e.g. "figure-notes", "figure-components" or "reprinted-adapted-table-figure". Returns the detailed rule in tableFigureRule.rules, plus citation, reference and permission treatment. Do not use reviewSectionId, caseId or a generic ruleId for these rules.'; + +// Use the catalogue actually returned by this version, rather than promising +// ids absent from the installed package. Also works with the hosted 2.x bundle. +const lookups: Record> = { + 'principles-ethics': [['principlesEthicsRuleId', 'availablePrinciplesEthicsRules', 'principlesEthicsRule']], + citation: [['citationRuleId', 'availableCitationRules', 'citationRule'], ['caseId', 'availableVerifiedCases', 'case'], ['peruLegalCaseId', 'availablePeruLegalCases', 'legalCase']], + reference: [['referenceRuleId', 'availableReferenceRules', 'referenceRule'], ['caseId', 'availableVerifiedCases', 'case'], ['peruLegalCaseId', 'availablePeruLegalCases', 'legalCase']], + format: [['formatRuleId', 'availableFormatRules', 'formatRule']], + reporting: [['reportingRuleId', 'availableReportingRules', 'reportingRule']], + 'writing-style': [['writingStyleRuleId', 'availableWritingStyleRules', 'writingStyleRule']], + 'bias-free-language': [['biasFreeLanguageRuleId', 'availableBiasFreeLanguageRules', 'biasFreeLanguageRule']], + mechanics: [['mechanicsRuleId', 'availableMechanicsRules', 'mechanicsRule']], + 'table-figure': [['tableFigureRuleId', 'availableTableFigureRules', 'tableFigureRule']], + legal: [['legalRuleId', 'availableLegalRules', 'legalRule'], ['peruLegalCaseId', 'availablePeruLegalCases', 'legalCase']], + publication: [['publicationRuleId', 'availablePublicationRules', 'publicationRule']], +}; + +export function addApa7DetailDiscovery(topic: string, payload: Record) { + const selectors = (lookups[topic] ?? []).filter(([, catalogue]) => payload[catalogue]?.length); + if (topic === 'review' && payload.reviewSkill?.selection === 'all') { + selectors.push(['reviewSectionId', 'reviewSkill.catalog', 'reviewSkill']); + } + if (!selectors.length) return { ...payload, guidanceScope: topic === 'course-requirements' ? 'course-workflow' : 'selected-guidance' }; + const examples = selectors.flatMap(([parameter, catalogue, resultField]) => { + const ids: string[] = parameter === 'reviewSectionId' + ? payload.reviewSkill.catalog.map((section: { id: string }) => section.id) + : payload[catalogue]; + const preferred = topic === 'table-figure' + ? ['figure-notes', 'figure-components', 'reprinted-adapted-table-figure'] + : topic === 'review' ? ['figures', 'tables'] : []; + const selected = preferred.filter(id => ids.includes(id)); + return (selected.length ? selected : ids.slice(0, 1)).map(id => ({ + tool: 'campus_apa7_guidance', arguments: { topic, [parameter]: id }, resultField, + })); + }); + return { + ...payload, + guidanceScope: 'overview', + detailLookup: { + instructions: 'Esta respuesta es una vista general y un catálogo. Para una duda concreta, ejecuta las consultas de ejemplo con el selector exacto; no sustituyas las reglas detalladas por el resumen ni inventes nombres de parámetros. Usa un solo selector por llamada. Las reglas son orientación redactada por Campus, no una transcripción literal del manual.', + selectors: selectors.map(([parameter, availableIdsField, resultField]) => ({ parameter, availableIdsField, resultField })), + examples, + ...(topic === 'table-figure' ? { + upcSection: { tool: 'campus_apa7_guidance', arguments: { topic: 'review', reviewSectionId: 'figures' }, resultField: 'reviewSkill.considerations.items' }, + warning: 'Que una figura propia no necesite atribución externa no establece por sí solo que deba llevar «Nota. Elaboración propia.»; comprueba las reglas detalladas y las instrucciones reales del docente.', + } : {}), + }, + }; +} diff --git a/src/providers/academic/apa7-mcp-tools.ts b/src/providers/academic/apa7-mcp-tools.ts index 8c63e4ef..64b00c05 100644 --- a/src/providers/academic/apa7-mcp-tools.ts +++ b/src/providers/academic/apa7-mcp-tools.ts @@ -21,6 +21,7 @@ import { mechanicsRuleId, mechanicsRules, getMechanicsRule, type MechanicsRuleId import { publicationRuleId, publicationRules, getPublicationRule, type PublicationRuleId } from './apa7-publication-rules.js'; import { principlesEthicsRuleId, principlesEthicsRules, getPrinciplesEthicsRule, type PrinciplesEthicsRuleId } from './apa7-principles-ethics-rules.js'; import { loadApa7ReviewSkill } from './apa7-review-skill.js'; +import { addApa7DetailDiscovery, APA7_GUIDANCE_DESCRIPTION, APA7_TABLE_FIGURE_SELECTOR_DESCRIPTION } from './apa7-guidance-discovery.js'; const topic = z.enum(['principles-ethics', 'citation', 'reference', 'format', 'reporting', 'writing-style', 'bias-free-language', 'mechanics', 'table-figure', 'legal', 'publication', 'review', 'course-requirements']); const sourceType = z.enum([ @@ -426,8 +427,8 @@ export function registerAcademicTools( options: { authorize: () => boolean | Promise }, ) { server.registerTool('campus_apa7_guidance', { - description: 'Get Spanish APA 7 guidance and templates. For reviewing or correcting a UPC paper, use topic=review: it loads the apa7-review-campus skill with official Biblioteca UPC criteria, source URLs and verification dates for the client agent to apply to the supplied paper. Does not require Blackboard login and never invents metadata.', - inputSchema: { + description: APA7_GUIDANCE_DESCRIPTION, + inputSchema: z.strictObject({ topic: topic.describe('The APA 7 help needed; review loads a source-grounded review skill for the client agent, not an automatic verdict on a document'), reviewSectionId: z.string().min(1).max(80).optional().describe('With topic=review only: select a UPC section id from reviewSkill.catalog to retrieve all its schemas, considerations, exceptions and conditional safeguards, including notes without examples, plus detailed source evidence'), sourceType: sourceType.optional().describe('Source type for a reference template; citation requests specialize personal communications and otherwise use caseId or citationRuleId'), @@ -439,12 +440,12 @@ export function registerAcademicTools( writingStyleRuleId: writingStyleRuleId.optional().describe('Verified Spanish-language APA 7 writing-style and grammar rule from chapter 4'), biasFreeLanguageRuleId: biasFreeLanguageRuleId.optional().describe('Verified APA 7 bias-free language rule from chapter 5, with citation and reference treatment'), mechanicsRuleId: mechanicsRuleId.optional().describe('Verified Spanish-language APA 7 mechanics rule from chapter 6'), - tableFigureRuleId: tableFigureRuleId.optional().describe('Verified APA 7 table/figure rule from chapter 7, including citation, reference and permission treatment'), + tableFigureRuleId: tableFigureRuleId.optional().describe(APA7_TABLE_FIGURE_SELECTOR_DESCRIPTION), legalRuleId: legalRuleId.optional().describe('Verified APA 7 legal-reference rule from chapter 11; jurisdiction-specific and never transferable by analogy'), peruLegalCaseId: peruLegalCaseId.optional().describe('Peruvian legal-source profile with citation, reference, required metadata and official verification sources'), publicationRuleId: publicationRuleId.optional().describe('Verified APA 7 publication, copyright, permission and post-publication rule from chapter 12'), principlesEthicsRuleId: principlesEthicsRuleId.optional().describe('Verified APA 7 writing, publication-ethics and professional rule from chapter 1'), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ topic, reviewSectionId, sourceType, caseId, citationRuleId, referenceRuleId, formatRuleId, reportingRuleId, writingStyleRuleId, biasFreeLanguageRuleId, mechanicsRuleId, tableFigureRuleId, legalRuleId, peruLegalCaseId, publicationRuleId, principlesEthicsRuleId }) => { if (!options?.authorize) { @@ -458,11 +459,11 @@ export function registerAcademicTools( const hasSelectedRule = Object.values(selectors).some(value => value !== undefined); const reviewSkill = topic === 'review' ? await loadApa7ReviewSkill(reviewSectionId) : undefined; return { - content: [{ type: 'text', text: JSON.stringify({ + content: [{ type: 'text', text: JSON.stringify(addApa7DetailDiscovery(topic, { ...guidanceFor(topic, sourceType, caseId, citationRuleId, referenceRuleId, formatRuleId, reportingRuleId, writingStyleRuleId, biasFreeLanguageRuleId, mechanicsRuleId, tableFigureRuleId, legalRuleId, peruLegalCaseId, publicationRuleId, principlesEthicsRuleId), ...(reviewSkill ? { sourceGuide: reviewSkill.rulebook.entryUrl, reviewSkill } : {}), ...(hasSelectedRule ? {} : availableSelectorsForTopic(topic)), - }) }], + })) }], }; }); } diff --git a/tests/apa7-detail-discovery.test.ts b/tests/apa7-detail-discovery.test.ts new file mode 100644 index 00000000..2b37d3e3 --- /dev/null +++ b/tests/apa7-detail-discovery.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { registerAcademicTools } from '../src/providers/academic/apa7-mcp-tools.js'; + +test('MCP overview examples retrieve actual detailed rules for every topic', async () => { + const server = new McpServer({ name: 'apa-detail', version: '1' }); + let entitled = true; + registerAcademicTools(server, { authorize: () => entitled }); + const client = new Client({ name: 'apa-detail-client', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(a); + await client.connect(b); + const call = async (args: Record) => { + const result = await client.callTool({ name: 'campus_apa7_guidance', arguments: args }); + assert.ok(!result.isError, JSON.stringify(result)); + return JSON.parse((result.content as Array<{ text: string }>)[0].text); + }; + try { + const { tools } = await client.listTools(); + const tool = tools.find(tool => tool.name === 'campus_apa7_guidance')!; + assert.match(tool.description!, /tableFigureRuleId.*figure-notes/); + assert.equal(tool.inputSchema.additionalProperties, false); + for (const topic of ['principles-ethics', 'citation', 'reference', 'format', 'reporting', 'writing-style', 'bias-free-language', 'mechanics', 'table-figure', 'legal', 'publication', 'review']) { + const overview = await call({ topic }); + assert.equal(overview.guidanceScope, 'overview', topic); + assert.ok(overview.detailLookup.examples.length, topic); + for (const example of overview.detailLookup.examples) { + const detail = await call(example.arguments); + assert.equal(detail.guidanceScope, 'selected-guidance'); + assert.ok(detail[example.resultField], example.resultField); + assert.equal(detail.detailLookup, undefined); + if (topic === 'table-figure') assert.ok(detail.tableFigureRule.rules.length > 1); + if (topic === 'review') assert.ok(detail.reviewSkill.considerations.items.length); + } + if (topic === 'table-figure') { + assert.deepEqual(overview.detailLookup.examples.map((r: any) => r.arguments.tableFigureRuleId), ['figure-notes', 'figure-components', 'reprinted-adapted-table-figure']); + const upc = await call(overview.detailLookup.upcSection.arguments); + assert.equal(upc.reviewSkill.selection, 'figures'); + } + } + const typo = await client.callTool({ name: tool.name, arguments: { topic: 'table-figure', ruleId: 'figure-notes' } }); + assert.equal(typo.isError, true, 'an unknown selector must not silently return the summary'); + const incompatible = await client.callTool({ name: tool.name, arguments: { topic: 'review', tableFigureRuleId: 'figure-notes' } }); + assert.equal(incompatible.isError, true); + entitled = false; + const denied = await client.callTool({ name: tool.name, arguments: { topic: 'table-figure', tableFigureRuleId: 'figure-notes' } }); + assert.equal(denied.isError, true); + } finally { + await client.close(); + await server.close(); + } +});