Skip to content

Commit 48e8f0f

Browse files
authored
Merge pull request #8 from superdoc-dev/caio/ooxml-mcp-local-element-resolution
feat(mcp): resolve local-element declarations on top-level miss
2 parents ff8bffd + e983913 commit 48e8f0f

6 files changed

Lines changed: 436 additions & 12 deletions

File tree

apps/mcp-server/src/ooxml-queries.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,25 @@ export interface LocalNameHit {
727727
namespaceUri: string;
728728
}
729729

730+
/**
731+
* A local element declaration found inside a parent group / complexType /
732+
* other owning symbol. Returned by `findLocalElementsInNamespace` and used
733+
* by the attribute/children/element dispatchers to fall back from a
734+
* missed top-level lookup to the inline declaration.
735+
*/
736+
export interface LocalElementHit {
737+
id: number;
738+
localName: string;
739+
/** Clark-form type ref, e.g. `{ns}CT_OnOff`. May be null for inline complexType. */
740+
typeRef: string | null;
741+
parentId: number;
742+
parentLocalName: string;
743+
/** `group`, `complexType`, or `element` (rare). */
744+
parentKind: string;
745+
vocabularyId: string;
746+
namespaceUri: string;
747+
}
748+
730749
/**
731750
* Find top-level symbols with this local name across all namespaces in a
732751
* profile. Used to power "did you mean?" suggestions when an exact lookup
@@ -776,3 +795,55 @@ export async function findLocalNameAcrossNamespaces(
776795
namespaceUri: r.namespace_uri as string,
777796
}));
778797
}
798+
799+
/**
800+
* Find local-element declarations (xsd:element name="X" declared inline
801+
* inside a complexType, group, or another element) for a given local
802+
* name in a single namespace. Used as a fallback when a top-level
803+
* `lookupElement` misses: many OOXML elements an agent encounters in
804+
* real documents (e.g. `w:cs`, `w:lang` inside `EG_RPrBase`) are local
805+
* and so have no global qname identity, but their owning group/type
806+
* does, and that's all we need to resolve attributes / children.
807+
*
808+
* Scoped by namespace on purpose: surfacing local elements from other
809+
* vocabularies would noise up the result without helping the agent who
810+
* just typed `w:cs`.
811+
*
812+
* Returns rows in their canonical order (by parent kind, then parent
813+
* name) so callers can present "first hit" deterministically.
814+
*/
815+
export async function findLocalElementsInNamespace(
816+
sql: Sql,
817+
localName: string,
818+
namespace: string,
819+
profile: string,
820+
): Promise<LocalElementHit[]> {
821+
const rows = await sql`
822+
SELECT s.id, s.local_name, s.type_ref,
823+
s.parent_symbol_id AS parent_id,
824+
parent.local_name AS parent_local_name,
825+
parent.kind AS parent_kind,
826+
s.vocabulary_id, ns.uri AS namespace_uri
827+
FROM xsd_symbols s
828+
JOIN xsd_symbols parent ON parent.id = s.parent_symbol_id
829+
JOIN xsd_symbol_profiles sp ON sp.symbol_id = s.id
830+
JOIN xsd_namespaces ns ON ns.id = sp.namespace_id
831+
JOIN xsd_profiles p ON p.id = sp.profile_id
832+
WHERE s.local_name = ${localName}
833+
AND s.kind = 'element'
834+
AND s.parent_symbol_id IS NOT NULL
835+
AND ns.uri = ${namespace}
836+
AND p.name = ${profile}
837+
ORDER BY parent.kind, parent.local_name
838+
`;
839+
return rows.map((r: Record<string, unknown>) => ({
840+
id: r.id as number,
841+
localName: r.local_name as string,
842+
typeRef: r.type_ref as string | null,
843+
parentId: r.parent_id as number,
844+
parentLocalName: r.parent_local_name as string,
845+
parentKind: r.parent_kind as string,
846+
vocabularyId: r.vocabulary_id as string,
847+
namespaceUri: r.namespace_uri as string,
848+
}));
849+
}

apps/mcp-server/src/ooxml-tools.ts

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
type AttrEntry,
1616
type ChildEdge,
1717
type EnumEntry,
18+
findLocalElementsInNamespace,
1819
findLocalNameAcrossNamespaces,
1920
getAttributes,
2021
getChildren,
2122
getEnums,
2223
getNamespaceInfo,
2324
knownPrefixes,
25+
type LocalElementHit,
2426
type LocalNameHit,
2527
listNamespaces,
2628
lookupElement,
@@ -216,6 +218,17 @@ export async function runOoxmlTool(
216218
if (!q.ok) return formatNotFound(`could not parse qname: ${q.reason}`);
217219
const hit = await lookupElement(sql, q.qname.namespace, q.qname.localName, profile);
218220
if (!hit) {
221+
// No global element. Apply the same single/ambiguous policy as
222+
// ooxml_attributes / ooxml_children so an element name with
223+
// disagreeing local declarations (the tblGrid case) doesn't
224+
// promote locals[0] as the answer.
225+
const local = await resolveLocalElement(sql, q.qname, profile);
226+
if (local.kind === "single") {
227+
return formatLocalElementReport(q.qname, local.first, local.locals, profile);
228+
}
229+
if (local.kind === "ambiguous") {
230+
return formatLocalElementAmbiguous(q.qname, local.locals);
231+
}
219232
const alts = await findLocalNameAcrossNamespaces(sql, q.qname.localName, profile, {
220233
kind: "element",
221234
});
@@ -264,6 +277,20 @@ export async function runOoxmlTool(
264277
}
265278
}
266279
if (!typeSym) {
280+
// Top-level lookup missed. Many elements an agent sees in real
281+
// .docx (w:cs, w:rtl, w:lang, w:dir, w:bdo, ...) are declared
282+
// inline inside groups / complexTypes; resolve through that
283+
// before falling back to cross-vocab did-you-mean.
284+
const local = await resolveLocalElement(sql, q.qname, profile);
285+
if (local.kind === "single") {
286+
const children = await getChildren(sql, local.typeSym.id, profile);
287+
return formatChildrenReport(null, local.typeSym, children, profile, {
288+
resolvedFromLocal: local.first,
289+
});
290+
}
291+
if (local.kind === "ambiguous") {
292+
return formatLocalElementAmbiguous(q.qname, local.locals);
293+
}
267294
const alts = await findLocalNameAcrossNamespaces(sql, q.qname.localName, profile);
268295
return formatNotFound(
269296
`children for ${q.qname.localName} in namespace ${q.qname.namespace}`,
@@ -287,6 +314,16 @@ export async function runOoxmlTool(
287314
}
288315
}
289316
if (!typeSym) {
317+
const local = await resolveLocalElement(sql, q.qname, profile);
318+
if (local.kind === "single") {
319+
const attrs = await getAttributes(sql, local.typeSym.id, profile);
320+
return formatAttributesReport(null, local.typeSym, attrs, profile, {
321+
resolvedFromLocal: local.first,
322+
});
323+
}
324+
if (local.kind === "ambiguous") {
325+
return formatLocalElementAmbiguous(q.qname, local.locals);
326+
}
290327
const alts = await findLocalNameAcrossNamespaces(sql, q.qname.localName, profile);
291328
return formatNotFound(
292329
`attributes for ${q.qname.localName} in namespace ${q.qname.namespace}`,
@@ -414,13 +451,23 @@ function formatChildrenReport(
414451
type: SymbolHit,
415452
children: ChildEdge[],
416453
profile: string,
454+
opts: { resolvedFromLocal?: LocalElementHit } = {},
417455
): string {
418456
const lines: string[] = [];
419-
const heading = element
420-
? `Children of ${element.localName} (via type ${type.localName})`
421-
: `Children of ${type.localName}`;
457+
const local = opts.resolvedFromLocal;
458+
const heading = local
459+
? `Children of ${local.localName} (resolved via local element in ${local.parentKind} ${local.parentLocalName}, type ${type.localName})`
460+
: element
461+
? `Children of ${element.localName} (via type ${type.localName})`
462+
: `Children of ${type.localName}`;
422463
lines.push(`## ${heading}`);
423464
lines.push("");
465+
if (local) {
466+
lines.push(
467+
`_\`${local.localName}\` has no top-level qname; it's a local element declared in ${local.parentKind} \`${local.parentLocalName}\`. Children come from its declared type \`${type.localName}\`._`,
468+
);
469+
lines.push("");
470+
}
424471
lines.push(`- profile: ${profile}`);
425472
lines.push(`- type vocabulary: ${type.vocabularyId}`);
426473
lines.push(`- type namespace: ${type.namespaceUri}`);
@@ -454,13 +501,23 @@ function formatAttributesReport(
454501
type: SymbolHit,
455502
attrs: AttrEntry[],
456503
profile: string,
504+
opts: { resolvedFromLocal?: LocalElementHit } = {},
457505
): string {
458506
const lines: string[] = [];
459-
const heading = element
460-
? `Attributes of ${element.localName} (via type ${type.localName})`
461-
: `Attributes of ${type.localName}`;
507+
const local = opts.resolvedFromLocal;
508+
const heading = local
509+
? `Attributes of ${local.localName} (resolved via local element in ${local.parentKind} ${local.parentLocalName}, type ${type.localName})`
510+
: element
511+
? `Attributes of ${element.localName} (via type ${type.localName})`
512+
: `Attributes of ${type.localName}`;
462513
lines.push(`## ${heading}`);
463514
lines.push("");
515+
if (local) {
516+
lines.push(
517+
`_\`${local.localName}\` has no top-level qname; it's a local element declared in ${local.parentKind} \`${local.parentLocalName}\`. Attributes come from its declared type \`${type.localName}\`._`,
518+
);
519+
lines.push("");
520+
}
464521
lines.push(`- profile: ${profile}`);
465522
lines.push(`- type vocabulary: ${type.vocabularyId}`);
466523
if (type.sourceName) lines.push(`- source: ${type.sourceName}`);
@@ -664,3 +721,101 @@ function formatPackagePartNotFound(
664721
);
665722
return lines.join("\n");
666723
}
724+
725+
// --- Local element resolution ------------------------------------------
726+
727+
type LocalResolution =
728+
| { kind: "single"; typeSym: SymbolHit; first: LocalElementHit; locals: LocalElementHit[] }
729+
| { kind: "ambiguous"; locals: LocalElementHit[] }
730+
| { kind: "none" };
731+
732+
/**
733+
* Try to resolve a missed top-level qname through local element declarations.
734+
*
735+
* Rules:
736+
* - 0 hits → none. Caller falls back to cross-vocab did-you-mean.
737+
* - >=1 hits, all with the same non-null type_ref that resolves → single.
738+
* The first local hit is preserved for parent-context display.
739+
* - >=1 hits but the type_refs disagree (or none resolve) → ambiguous.
740+
* We refuse to guess and let the caller render a disambiguation list.
741+
*/
742+
async function resolveLocalElement(
743+
sql: Sql,
744+
qname: { namespace: string; localName: string },
745+
profile: string,
746+
): Promise<LocalResolution> {
747+
const locals = await findLocalElementsInNamespace(sql, qname.localName, qname.namespace, profile);
748+
if (locals.length === 0) return { kind: "none" };
749+
750+
// "Single" requires every local hit to share the same non-null type_ref.
751+
// A mix of typed and inline-typed (null type_ref) declarations - even if
752+
// the typed ones agree - is genuinely ambiguous: the inline declaration
753+
// has its own content model that the type symbol can't represent. Don't
754+
// silently filter nulls out.
755+
const firstRef = locals[0].typeRef;
756+
if (firstRef && locals.every((l) => l.typeRef === firstRef)) {
757+
const typeSym = await lookupSymbolByTypeRef(sql, firstRef, profile);
758+
if (typeSym) {
759+
return { kind: "single", typeSym, first: locals[0], locals };
760+
}
761+
}
762+
// Either multiple distinct type_refs, at least one null type_ref alongside
763+
// a typed one, or the single type_ref didn't resolve (dangling). Surface
764+
// every declaration and let the caller pick.
765+
return { kind: "ambiguous", locals };
766+
}
767+
768+
function formatLocalElementAmbiguous(
769+
qname: { namespace: string; localName: string },
770+
locals: LocalElementHit[],
771+
): string {
772+
const lines: string[] = [];
773+
lines.push(`## Ambiguous local element \`${qname.localName}\` in namespace ${qname.namespace}`);
774+
lines.push("");
775+
lines.push(
776+
`\`${qname.localName}\` is declared inline in multiple places with different types; no single answer to return.`,
777+
);
778+
lines.push("");
779+
lines.push("| owner | owner kind | type_ref |");
780+
lines.push("| --- | --- | --- |");
781+
for (const l of locals) {
782+
lines.push(`| \`${l.parentLocalName}\` | ${l.parentKind} | ${l.typeRef ?? "_(none)_"} |`);
783+
}
784+
lines.push("");
785+
lines.push(
786+
"Resolve the parent owner (e.g. `ooxml_attributes` on the group/complexType) or pass the desired type directly.",
787+
);
788+
return lines.join("\n");
789+
}
790+
791+
function formatLocalElementReport(
792+
qname: { namespace: string; localName: string },
793+
first: LocalElementHit,
794+
locals: LocalElementHit[],
795+
profile: string,
796+
): string {
797+
// Invariant: callers only reach here via `resolveLocalElement` returning
798+
// `single`, which means every entry in `locals` shares `first.typeRef`.
799+
// The ambiguous case takes a different code path
800+
// (formatLocalElementAmbiguous), so "also declared" here doesn't risk
801+
// implying agreement that doesn't exist.
802+
const lines: string[] = [];
803+
lines.push(`## Local element: ${first.localName}`);
804+
lines.push("");
805+
lines.push(
806+
`_\`${first.localName}\` has no top-level qname in this namespace. It's declared inline inside ${first.parentKind} \`${first.parentLocalName}\`. Call \`ooxml_attributes\` or \`ooxml_children\` with the same qname to follow its type._`,
807+
);
808+
lines.push("");
809+
lines.push(`- profile: ${profile}`);
810+
lines.push(`- namespace: ${qname.namespace}`);
811+
lines.push(`- vocabulary: ${first.vocabularyId}`);
812+
if (first.typeRef) lines.push(`- type_ref: ${first.typeRef}`);
813+
lines.push("");
814+
if (locals.length > 1) {
815+
lines.push(`Also declared in ${locals.length - 1} other local context(s) with the same type:`);
816+
for (const l of locals.slice(1)) {
817+
lines.push(`- ${l.parentKind} \`${l.parentLocalName}\``);
818+
}
819+
}
820+
return lines.join("\n");
821+
}

tests/ingest-xsd/fixtures/main.xsd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@
108108
<xsd:element name="shared" type="xsd:string"/>
109109
</xsd:sequence>
110110
</xsd:complexType>
111+
<!-- Mirrors WML's EG_RPrBase / w:cs case: a local element declared inside
112+
a group, typed to a complexType. ooxml_attributes for this element's
113+
qname should resolve through the group to the type, returning the
114+
type's attributes. -->
115+
<xsd:group name="EG_LocalCase">
116+
<xsd:choice>
117+
<xsd:element name="local_para" type="CT_Para"/>
118+
</xsd:choice>
119+
</xsd:group>
111120
<!-- Mirrors WML's *Change types: a complexContent/restriction that
112121
carries no attribute redeclarations should still inherit the base's
113122
attribute uses, not silently drop them. -->

0 commit comments

Comments
 (0)