mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd5eca98a | ||
|
|
ed307b8d52 | ||
|
|
17e8deebfd | ||
|
|
454d7121f5 | ||
|
|
02243e21f3 | ||
|
|
4c1f2537e1 | ||
|
|
229ebd49d3 | ||
|
|
41493cf39b | ||
|
|
9304152a95 | ||
|
|
f1622b32d5 |
No files matched your search
@@ -1996,6 +1996,26 @@ check full-profile local-path rules. Compact MCP workflow tests verify canonical
|
||||
rejection of invalid inputs before provider work, independent of how much detail
|
||||
the selected advertisement retains.
|
||||
|
||||
### Retained-view guidance
|
||||
|
||||
`inspect_analysis_view` advertises the shared source, Evidence and paging rules.
|
||||
Its format-specific selectors are available on demand through the existing
|
||||
guidance tools: search with `{"query":"inspect_analysis_view <format or view kind>"}`,
|
||||
then pass a returned locator to `read_guidance_section`. For example, search for
|
||||
`inspect_analysis_view OpenAPI` before selecting retained API operations.
|
||||
These compiled tool sections ship in the runtime alongside the installed workflow
|
||||
guides; they use the same bundle digest, section selection and stale-locator checks.
|
||||
They are documentation, not target observations or Evidence. CLI callers use
|
||||
`rea guidance --action search --query "inspect_analysis_view OpenAPI"`, then
|
||||
`rea guidance --action read --locator <locator>` through the same owner.
|
||||
|
||||
In hot/cold catalogs, first discover `search_guidance` and `read_guidance_section`
|
||||
by their exact names. Keyword tool discovery also searches the compiled tool
|
||||
guidance, so moving a selector out of the advertised description does not remove
|
||||
its tool-discovery keywords. Full input/output schemas and canonical validation
|
||||
remain unchanged; a full-catalog client does not need catalog refresh to use the
|
||||
guidance tools.
|
||||
|
||||
<a id="hotcold-catalog-discovery"></a>
|
||||
|
||||
### Hot/cold catalog discovery
|
||||
@@ -2005,6 +2025,13 @@ clients that refresh `tools/list` after `notifications/tools/list_changed`.
|
||||
The default, `full`, retains the complete upfront advertisement. Restart the
|
||||
server to change profiles. See [client configuration](../getting-started/client-configuration.md#hotcold-catalogs).
|
||||
|
||||
Legacy connections receive list-change notifications unsolicited. On protocol
|
||||
revision `2026-07-28`, subscribe to tool-list changes with `subscriptions/listen`;
|
||||
the pinned JS SDK exposes `client.listen({toolsListChanged: true})` or the
|
||||
`ClientOptions.listChanged.tools` convenience handler. Registering a notification
|
||||
handler alone does not open a modern subscription. Refresh `tools/list` after
|
||||
the subscribed notification before calling newly loaded tools.
|
||||
|
||||
Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`,
|
||||
`get_evidence_bundle`, and the adapter's `discover_tools`.
|
||||
`inspect_analysis_view` is loaded on demand like other analysis tools. When a
|
||||
@@ -2014,7 +2041,7 @@ that accept `view` can answer a selected first question without loading the
|
||||
cross-domain view contract. Complete output schemas and canonical result
|
||||
validation are preserved when each tool loads.
|
||||
An explicit tool-family scope can remove any of those domain tools.
|
||||
`discover_tools` searches canonical names and descriptions using case-insensitive
|
||||
`discover_tools` searches canonical names, descriptions and compiled tool guidance using case-insensitive
|
||||
words, or accepts exact `names`. It loads the matching page and returns names
|
||||
and abbreviated descriptions with their configured input schemas, so the next
|
||||
call can use the returned arguments contract. Output schemas remain in
|
||||
|
||||
@@ -17,6 +17,7 @@ const [
|
||||
{ PROMPT_CONTRACTS },
|
||||
{ TOOL_CONTRACTS },
|
||||
{ getEvidenceInputContract },
|
||||
{ toolOutputSchemaWithMetadata },
|
||||
] = await Promise.all([
|
||||
read("server/createServer.js"),
|
||||
read("composition/binary.js"),
|
||||
@@ -25,6 +26,7 @@ const [
|
||||
read("contracts/promptContracts.js"),
|
||||
read("contracts/toolContracts.js"),
|
||||
read("domain/evidenceInputContract.js"),
|
||||
read("contracts/toolSchemaMetadata.js"),
|
||||
]);
|
||||
const recordCatalog = async (profile) => {
|
||||
// Catalog generation is independent of the host account and user configuration.
|
||||
@@ -59,7 +61,18 @@ const recordCatalog = async (profile) => {
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
const tools = (await client.listTools()).tools.map((tool) => {
|
||||
const contract = TOOL_CONTRACTS.find(({ name }) => name === tool.name);
|
||||
if (contract === undefined) throw new Error(`Missing tool ${tool.name}`);
|
||||
return {
|
||||
...tool,
|
||||
// Record the pre-codec schema. The SDK owns protocol-specific root
|
||||
// wrapping for both tools/list and tools/call on the deferred server.
|
||||
outputSchema: toolOutputSchemaWithMetadata(contract)[
|
||||
"~standard"
|
||||
].jsonSchema.output({ target: "draft-2020-12" }),
|
||||
};
|
||||
});
|
||||
const prompts = (await client.listPrompts()).prompts.map((prompt) => {
|
||||
const contract = PROMPT_CONTRACTS.find(
|
||||
(candidate) => candidate.name === prompt.name,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { readGuidanceSectionInputSchema } from "../contracts/guidanceContracts.js";
|
||||
import { MANAGED_SKILL_FILES } from "./SetupSkill.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import { parseGuidanceSections } from "./GuidanceSections.js";
|
||||
import {
|
||||
readGuidanceSectionWithReader,
|
||||
searchGuidanceWithReader,
|
||||
@@ -76,21 +78,33 @@ it("issues readable locators independently of heading length", async () => {
|
||||
await resources.close();
|
||||
});
|
||||
|
||||
it.each([2048, 2049])(
|
||||
"bounds the combined index across guides with %i sections each",
|
||||
it.each([4096, 4097])(
|
||||
"bounds the combined skill and tool index at %i sections",
|
||||
async (count) => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const compiled = parseGuidanceSections(
|
||||
new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [
|
||||
guide,
|
||||
content,
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (compiled === undefined) throw new Error("Invalid compiled guidance");
|
||||
const remaining = count - compiled.length;
|
||||
const result = await searchGuidanceWithReader(
|
||||
{ query: "needle", limit: 1 },
|
||||
async (path) =>
|
||||
path === MANAGED_SKILL_FILES[0] || path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(count)
|
||||
: "",
|
||||
path === MANAGED_SKILL_FILES[0]
|
||||
? "# needle\n".repeat(Math.floor(remaining / 2))
|
||||
: path === MANAGED_SKILL_FILES[1]
|
||||
? "# needle\n".repeat(Math.ceil(remaining / 2))
|
||||
: "",
|
||||
resources,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { status: count === 2048 ? "matches" : "bundle_unavailable" },
|
||||
value: { status: count === 4096 ? "matches" : "bundle_unavailable" },
|
||||
});
|
||||
await resources.close();
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { MANAGED_SKILL_FILES, readSkillFile } from "./SetupSkill.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import {
|
||||
MAX_SUBSECTION_POINTERS,
|
||||
readGuidanceSectionInputSchema,
|
||||
@@ -32,6 +33,13 @@ const SKILL_ROOT = `../../skills/${PRODUCT_IDENTITY.skillName}/`;
|
||||
const SOURCE = "installed_rea_guidance" as const;
|
||||
const MAX_GUIDE_FILE_BYTES = 1_048_576;
|
||||
const MAX_GUIDE_BUNDLE_BYTES = 4_194_304;
|
||||
const COMPILED_GUIDES = new Map(
|
||||
Object.values(TOOL_GUIDANCE).map(({ guide, content }) => [guide, content]),
|
||||
);
|
||||
const GUIDANCE_FILES: ReadonlySet<string> = new Set([
|
||||
...MANAGED_SKILL_FILES,
|
||||
...COMPILED_GUIDES.keys(),
|
||||
]);
|
||||
const sha256 = (value: string | Uint8Array): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
@@ -85,8 +93,11 @@ const readBundle = async (
|
||||
resources: ArtifactResourceScope,
|
||||
operation: string,
|
||||
): Promise<GuidanceBundleRead> => {
|
||||
const files = new Map<string, string>();
|
||||
let bundleBytes = 0;
|
||||
const files = new Map(COMPILED_GUIDES);
|
||||
let bundleBytes = [...files.values()].reduce(
|
||||
(bytes, content) => bytes + Buffer.byteLength(content, "utf8"),
|
||||
0,
|
||||
);
|
||||
try {
|
||||
for (const relativePath of MANAGED_SKILL_FILES) {
|
||||
const content = await readGuide(relativePath);
|
||||
@@ -712,7 +723,7 @@ const decodeLocator = (locator: string): LocatorIdentity | undefined => {
|
||||
typeof item.bundle !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.bundle) ||
|
||||
typeof item.guide !== "string" ||
|
||||
!(MANAGED_SKILL_FILES as readonly string[]).includes(item.guide) ||
|
||||
!GUIDANCE_FILES.has(item.guide) ||
|
||||
!Number.isInteger(item.ordinal) ||
|
||||
typeof item.section !== "string" ||
|
||||
!/^[a-f0-9]{64}$/u.test(item.section)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { createCatalogIdentity } from "./catalogIdentity.js";
|
||||
import { TOOL_GUIDANCE } from "./toolGuidance.js";
|
||||
|
||||
// Retained views are loaded like producers: their full cross-domain output
|
||||
// contract is unnecessary until a task actually queries retained analysis.
|
||||
@@ -27,7 +28,7 @@ const inputSchema = z
|
||||
.trim()
|
||||
.optional()
|
||||
.describe(
|
||||
"Case-insensitive words matched against tool names and descriptions; all words must match. Omit to browse.",
|
||||
"Case-insensitive words matched against tool names, descriptions and bundled tool guidance; all words must match. Omit to browse.",
|
||||
),
|
||||
names: z
|
||||
.array(z.string().min(1))
|
||||
@@ -98,6 +99,7 @@ export const progressiveCatalogIdentity = (
|
||||
const policy = {
|
||||
profile: "hot-cold",
|
||||
hot: [...HOT_TOOL_NAMES],
|
||||
guidance: TOOL_GUIDANCE,
|
||||
tool: {
|
||||
...DISCOVER_TOOLS,
|
||||
inputSchema: z.toJSONSchema(inputSchema, { io: "input" }),
|
||||
|
||||
@@ -49,7 +49,7 @@ export const registerGuidanceCommands = (
|
||||
resources: CliGuidanceResources = processGuidanceResources,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.guidance, {
|
||||
description: "Search or read the installed REA workflow guidance",
|
||||
description: "Search or read the installed REA workflow and tool guidance",
|
||||
args: z.object({}),
|
||||
options: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/** Format-specific documentation, indexed on demand rather than advertised with every tool. */
|
||||
export const ANALYSIS_VIEW_GUIDANCE = `# Retained analysis view guidance
|
||||
|
||||
Each section documents \`inspect_analysis_view\` for a compatible completed parent.
|
||||
Use \`search_guidance\` with \`inspect_analysis_view\` and a view kind, then read the
|
||||
returned locator with \`read_guidance_section\`. These are instructions, not Evidence.
|
||||
|
||||
## inspect_analysis_view: Native procedure references
|
||||
|
||||
Use native-procedure-references to page retained procedure reference or unresolved-call
|
||||
rows. Endpoint kind selected-procedure refers to the summary procedure, null preserves
|
||||
the original null, and shared carries an index into items.endpoints, where each other
|
||||
procedure is stored once per page. Exact source_address and target_address filters apply
|
||||
to reference rows.
|
||||
|
||||
## inspect_analysis_view: OpenAPI and GraphQL contracts
|
||||
|
||||
For inspect_api_contract OpenAPI parents, use kind api-contract, collection operations,
|
||||
with exact methods/paths, selected fields and matching-row pagination. For example:
|
||||
view {kind: "api-contract", collection: "operations", paths: ["/items"],
|
||||
fields: ["method", "path"], offset: 0, limit: 20}. This reads retained declarations;
|
||||
page exhaustion does not establish runtime endpoint behavior.
|
||||
|
||||
For inspect_api_contract parents, graphql-contract pages retained SDL types/fields,
|
||||
operation definitions, field references, fragment-spreads or diagnostics with exact
|
||||
filters and selected columns.
|
||||
|
||||
## inspect_analysis_view: JavaScript routes
|
||||
|
||||
Use javascript-routes collections to query route patterns, source-linked occurrences,
|
||||
source status, unknowns or summary counts from retained JavaScript application or
|
||||
web-bundle Evidence.
|
||||
|
||||
## inspect_analysis_view: Web bundle endpoints
|
||||
|
||||
Use web-bundle collections endpoints or inferences to page endpoint observations and
|
||||
inferences from retained analyze_web_bundle Evidence; summary returns target URL,
|
||||
origin, and counts.
|
||||
|
||||
## inspect_analysis_view: Native feature traces
|
||||
|
||||
For retained trace_feature Evidence, kind native-feature-trace pages matches, references
|
||||
or unknowns; matches filter exact address, references filter exact source_address and
|
||||
target_address, source_index identifies each original row, and list_unknowns pages
|
||||
durable question heads.
|
||||
|
||||
## inspect_analysis_view: Recorded arrays and pseudocode
|
||||
|
||||
Inspect selected views of supported completed Evidence without repeating analysis. Use
|
||||
recorded-array with an exact raw_result object-member path to page a provider-recorded
|
||||
array when the producer supplies that path; rows keep their raw meaning and the parent
|
||||
retains coverage. For procedure_pseudo_code Evidence, summary reports text availability
|
||||
and the original selection; kind native with facet pseudocode pages the original UTF-16
|
||||
text. Other native facets require completed analyze_function Evidence. Select a view
|
||||
compatible with the original parent operation; inspect_analysis_view does not rerun it.
|
||||
|
||||
## inspect_analysis_view: Historical command and capture records
|
||||
|
||||
Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once,
|
||||
source parameters, completion facts and parent identity. They do not establish current
|
||||
target state. Historical capture summaries return identity and counts without bodies or
|
||||
archive inventories. Capture views page retained records: view {kind: "capture", detail:
|
||||
"preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields
|
||||
and binary descriptors, complete returns exact records with all sidecars. Offsets follow
|
||||
the selected parent record order, preserving original producer ordinals. Paging never
|
||||
reopens the source or reruns the decoder.
|
||||
|
||||
## inspect_analysis_view: Native export tables
|
||||
|
||||
Native export tables use kind native-exports, collection exports/issues, exact names or
|
||||
original indices, selected fields and matching-row pagination; parent Evidence plus
|
||||
source_index identifies each row. Source coverage stays separate from page coverage.
|
||||
|
||||
## inspect_analysis_view: Erlang BEAM
|
||||
|
||||
BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact
|
||||
names/indices/fields and matching-row pages; import modules intersects function names.
|
||||
source_index identifies original parent-and-collection rows, preserving every arity and
|
||||
raw atom index. beam.counts preserves null missing tables versus decoded empty tables;
|
||||
runtime absence remains unknown.
|
||||
|
||||
## inspect_analysis_view: Python PYC
|
||||
|
||||
PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging;
|
||||
source_index scopes every same-name alternative to the parent, nested_code keeps
|
||||
code-constant references, and pyc.coverage preserves source coverage.
|
||||
|
||||
## inspect_analysis_view: Go metadata
|
||||
|
||||
Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices,
|
||||
fields and pagination. Row index refers to the original collection; empty selections
|
||||
return no rows, and dependency coverage remains unknown.
|
||||
|
||||
## inspect_analysis_view: Rust dependencies
|
||||
|
||||
Rust metadata uses kind rust, collection packages/edges, exact names/versions or
|
||||
original indices, selected fields, edge from_indices/to_indices and matching-row
|
||||
pagination. Embedded-record coverage is separate from unknown runtime dependencies.
|
||||
|
||||
## inspect_analysis_view: PCAP captures
|
||||
|
||||
For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni,
|
||||
protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses,
|
||||
or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained
|
||||
facts; source coverage remains explicit.
|
||||
|
||||
## inspect_analysis_view: Qlog traces
|
||||
|
||||
Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams,
|
||||
loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except
|
||||
traces, select trace_index; stream-specific facets also require exact stream_id and
|
||||
direction. Facts retain all-recorded-events-before-selection scope, and interval overlap
|
||||
or endpoint loss declarations do not prove retransmission or network loss.
|
||||
|
||||
## inspect_analysis_view: API surface
|
||||
|
||||
The api-surface view groups retained inspect_web_network_capture HAR requests by method,
|
||||
origin and format-rule path
|
||||
template; templated segments are inferences. It reads no headers or bodies and reports
|
||||
no query values.
|
||||
|
||||
## inspect_analysis_view: Environment manifest
|
||||
|
||||
The environment-manifest view projects retained inspect_web_network_capture HAR requests
|
||||
on the primary origin (most
|
||||
JSON-like records) into a replayable tool surface: view {kind: "environment-manifest",
|
||||
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"],
|
||||
include_query_parameter_names: true}. Each tool has a deterministic name, a path
|
||||
template and parameters from the four format rules plus the selected opt-in rules,
|
||||
observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and
|
||||
content-type sets and record ordinals; records are pointers into the parent, excluded
|
||||
groups carry a reason and three fixed unknowns are always stated. Templates, parameters
|
||||
and names are inferences; the view copies no header, cookie or body values and refuses
|
||||
HAR parents whose sensitivity declarations are missing or record sensitive values.
|
||||
Do not remove sensitivity declarations to bypass this refusal.
|
||||
|
||||
For capture_browser_scenario Evidence, environment-manifest requires
|
||||
capture.network.response_body or header_values to have been selected. It projects one
|
||||
record per network transaction in request order, relies on capture-time redaction of
|
||||
declared secrets, and leaves a transaction whose URL was redacted unrepresented.
|
||||
|
||||
## inspect_analysis_view: Browser scenario captures
|
||||
|
||||
Retained capture_browser_scenario Evidence supports summary and browser-scenario
|
||||
steps/events/transactions/text pages, preserving step_indices, event_kinds,
|
||||
transaction_ids and selected artifacts. For an environment-manifest projection, search
|
||||
for "inspect_analysis_view Environment manifest" and read its section; that projection
|
||||
has separate capture selection and redaction requirements.
|
||||
|
||||
## inspect_analysis_view: Environment state
|
||||
|
||||
The environment-state view extracts initial state from retained inspect_web_network_capture
|
||||
HAR or capture_browser_scenario Evidence. Use view {kind: "environment-state",
|
||||
template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"]}, selecting
|
||||
the same optional template_rules as the accompanying environment-manifest. It reads
|
||||
response bodies recorded before the
|
||||
first related write and reports entity types with an identity field (proved by a path
|
||||
parameter or inferred from a field name), entities in collection order with per-view
|
||||
values where views differ, documents for non-parameterized reads that are not entities
|
||||
and have one initial request line (item-shaped paths stay recorded), and a render plan
|
||||
per read tool: item and list views reproduce their latest recordings from the state (a
|
||||
list also learns the filters, search, sort and paging its recordings verify, and answers
|
||||
new values of them from the state), and every other read tool is a recorded view with a
|
||||
reason. It also reports a write plan per state-changing tool (create, update, delete or
|
||||
document, built from its first successful call) and write_clock; a plan is served only
|
||||
when replaying the capture shows a later read confirming its effect and none
|
||||
contradicting it. Types and identity are inferences; values are recorded data, so it
|
||||
refuses HAR parents whose sensitivity declarations are missing or record sensitive
|
||||
values. Do not remove sensitivity declarations to bypass this refusal. A scenario
|
||||
parent must have selected capture.network.response_body; its values keep the capture
|
||||
redaction markers, and a write
|
||||
whose URL it redacted ends the state of every type.
|
||||
|
||||
## inspect_analysis_view: Environment coverage
|
||||
|
||||
The environment-coverage view summarizes initial-state projection from retained
|
||||
inspect_web_network_capture HAR or capture_browser_scenario Evidence without returning
|
||||
its entities. Use view {kind: "environment-coverage", template_rules:
|
||||
["after-uid-literal", "opaque-token", "sibling-variance"]}, selecting the same optional
|
||||
template_rules as the accompanying environment-manifest and environment-state views.
|
||||
reads.state and reads.recorded count
|
||||
read tools answered from the state and replayed (recorded_reasons per view reason),
|
||||
writes.served lists the served write tools, and writes.withheld lists each other write
|
||||
tool with its reason, plan record, contradicting read and a hint about what to record
|
||||
next. It refuses HAR parents whose sensitivity declarations are missing or record
|
||||
sensitive values; do not remove sensitivity declarations to bypass this refusal.
|
||||
A scenario parent must have selected capture.network.response_body, and retained
|
||||
capture-time redaction markers remain authoritative. For state modelling and write
|
||||
confirmation rules, search for "inspect_analysis_view Environment state" and read
|
||||
its section.
|
||||
|
||||
## inspect_analysis_view: Sources and summaries
|
||||
|
||||
For a retained record use source: {kind: "retained-evidence", evidence_id:
|
||||
"<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary
|
||||
receipt. Source kind is required. Portable source uses {kind: "inline", evidence:
|
||||
<complete Evidence>}. A completed Evidence JSON file on this host uses {kind:
|
||||
"evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects
|
||||
it like retained Evidence, so a large capture is bounded by the file reader rather than
|
||||
by inline-input accounting. The file may also be an Evidence bundle written by CLI
|
||||
--evidence_output or export_evidence_bundle; select evidence_id when it contains more
|
||||
than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder,
|
||||
EVM, managed artifact, Android package and Dart AOT sources, request a summary with
|
||||
view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives
|
||||
validation, counts, WAT digest/length, tool profile and execution/network states without
|
||||
reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module,
|
||||
or a stable page.
|
||||
|
||||
## inspect_analysis_view: JavaScript modules and facts
|
||||
|
||||
For an exact module's retained facts and source locations, search guidance for
|
||||
"read module without repeating analysis" and follow that retained-parent workflow.
|
||||
|
||||
Module pages include assets, bundled and source modules; use node_id for ambiguous or
|
||||
unavailable paths. Typed module references use view {kind: "javascript", collection:
|
||||
"modules", projection: "references", where: {path: "main.js", kind:
|
||||
"javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null
|
||||
selects unavailable paths). Rows include canonical identity, retained source-range count
|
||||
and observation support states. Source coverage and selection remain explicit;
|
||||
exhausting matching rows does not prove runtime loading or complete analysis. For exact
|
||||
file/native facts and processing gaps, use view {kind: "javascript-facts", where:
|
||||
{paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}.
|
||||
Rows page original observations with canonical identities and Evidence; missing
|
||||
properties remain omitted. Paths match recorded artifact-path/source-range locations;
|
||||
optional kinds filter nodes. To discover field names without values, opt into
|
||||
include_available_property_keys: true with property_keys: []. Canonical identity and
|
||||
Evidence are separate from properties; keep each node_id/observation_id pair together
|
||||
when composing results.
|
||||
|
||||
## inspect_analysis_view: Artifact inventory
|
||||
|
||||
Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths:
|
||||
["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges
|
||||
or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids;
|
||||
nodes accept artifact_ids. Batch filters intersect (omitted means all available, []
|
||||
means none); fields selects columns while retaining identity links. source_complete
|
||||
describes retained graph membership; source_coverage describes producer coverage,
|
||||
including unknown encrypted PCK directories. Page exhaustion does not prove absence in a
|
||||
partial source.
|
||||
|
||||
## inspect_analysis_view: WebAssembly
|
||||
|
||||
WASM views use kind wasm and facet
|
||||
sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets
|
||||
use UTF-16 code units and source forms remain exact.
|
||||
|
||||
## inspect_analysis_view: Mach-O
|
||||
|
||||
Mach-O views use kind macho and facet
|
||||
entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance;
|
||||
source_exhaustive false means unreturned rows may exist.
|
||||
|
||||
## inspect_analysis_view: Interface Builder
|
||||
|
||||
Interface Builder views use kind interface-builder and collection
|
||||
documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path.
|
||||
|
||||
## inspect_analysis_view: EVM interfaces
|
||||
|
||||
EVM summary returns all inferred interface candidates, carrier/decoded identities and
|
||||
explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet
|
||||
functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate
|
||||
rows, and text offsets/limits count UTF-16 code units (hex has two characters per
|
||||
decoded byte). Exhausting candidate pages does not establish global interface
|
||||
completeness.
|
||||
|
||||
## inspect_analysis_view: Managed artifacts and members
|
||||
|
||||
Managed artifact summaries preserve complete identity/classification, metadata status,
|
||||
module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed
|
||||
pages use kind managed and collection references/resources/attributes, retaining source
|
||||
coverage and build-local token scope; page exhaustion does not establish complete
|
||||
metadata. Managed member summaries keep identity, selection with unmatched names,
|
||||
coverage and per-collection counts; managed-members pages use collection
|
||||
types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.
|
||||
Exact names and declaring_types filters intersect before paging where supported; fields
|
||||
selects columns (distinct from collection: "fields"). Identity/source_index, module
|
||||
MVID, source coverage and unresolved filter counts remain; omit body to avoid IL
|
||||
anchors. Omitted fields preserves full rows.
|
||||
|
||||
## inspect_analysis_view: Android manifests
|
||||
|
||||
Android package summaries retain all declarations, engine identity and counts without
|
||||
decoded manifest text. Android manifest pages use kind android, facet manifest, offset
|
||||
and limit in UTF-16 code units; source_status preserves partial decoded coverage even
|
||||
when the retained text page is exhausted.
|
||||
|
||||
## inspect_analysis_view: Dart AOT
|
||||
|
||||
Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower
|
||||
bounds. String pages use kind dart-aot, collection
|
||||
package_uris/dart_uris/class_like_tokens; source coverage remains partial when the
|
||||
producer says partial.
|
||||
|
||||
## inspect_analysis_view: Protobuf
|
||||
|
||||
Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals,
|
||||
body_pointers and fields; field pages also select frame_indices, field_ids,
|
||||
field_numbers or schema_names. Byte identity, offset basis and body status remain
|
||||
present. Text and schema_value are opt-in page columns.
|
||||
|
||||
## inspect_analysis_view: APK signatures
|
||||
|
||||
APK signing views use kind apk-signature and collection signers/digests/lineage, with
|
||||
ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty.
|
||||
Declared identities and digest matches do not establish signature verification or
|
||||
authenticity.
|
||||
|
||||
## inspect_analysis_view: Model artifacts
|
||||
|
||||
Model views use kind model-artifact and collection metadata (keys), tensors
|
||||
(names/types) or types (derived totals), with indices, fields and paging. Header SHA-256
|
||||
scopes declarations; full-file identity remains unknown unless full hashing was
|
||||
selected. Arrays retain typed eight-element previews with explicit omissions.
|
||||
|
||||
## inspect_analysis_view: IL2CPP metadata
|
||||
|
||||
IL2CPP metadata views use kind il2cpp-metadata and collection
|
||||
sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues.
|
||||
Select indices, fields and pages;
|
||||
sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances
|
||||
accept exact names (native type names match retained rendered values, preserving
|
||||
same-name rows; null names never match), types also accept
|
||||
image_indices/namespaces/declaring_types, and methods/members accept declaring_types.
|
||||
Parameters and parameter_types accept method_indices; code_gen_modules accept
|
||||
image_indices; method_addresses and generic_method_instances accept addresses/states
|
||||
(generic_method_instances also method_indices), preserving every folded alternative;
|
||||
generic_method_issues accept rows. Row index is scoped to the metadata digest; native
|
||||
pages add binary_source. Uncollected native tables have null counts and remain
|
||||
unavailable.
|
||||
|
||||
## inspect_analysis_view: Desktop directories
|
||||
|
||||
Desktop directory views use kind desktop-application and collection
|
||||
files/omissions/candidates/components/next_probes, with fields and pagination; files and
|
||||
omissions accept exact paths or their stable IDs, and candidates accept families. Source
|
||||
coverage and stable row IDs remain available.
|
||||
|
||||
## inspect_analysis_view: Native load images
|
||||
|
||||
Native load-image Evidence uses kind native-load-image with collection
|
||||
source_files/mappings/relocations/entry_points/entry_context/checks/sections, original
|
||||
indices, collection-specific fields and offset/limit paging. Null collections remain
|
||||
unrecorded or inapplicable; pages preserve unsupported verification.
|
||||
|
||||
## inspect_analysis_view: Native inventories
|
||||
|
||||
Native inventory Evidence from list_names, list/search_procedures or list/search_strings
|
||||
uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows
|
||||
keep source_index and address even with selected fields. Summary counts describe
|
||||
retained rows; provider discovery coverage remains unknown.
|
||||
|
||||
## inspect_analysis_view: Native functions
|
||||
|
||||
Native function Evidence accepts kind summary for identity, availability and facet
|
||||
counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}.
|
||||
Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees,
|
||||
references, unresolved calls, referenced strings/names, native_api and value-flow
|
||||
observations. For parameter type paths when value flow is recorded, use facet:
|
||||
"value_flow_parameters"; its decompiler-observed path may be passed to
|
||||
inspect_native_data_type, but exact database lookup can be unavailable and does not by
|
||||
itself establish type identity. Use nonnegative offset and a positive caller-selected
|
||||
limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use
|
||||
rows.
|
||||
|
||||
## inspect_analysis_view: Batch decompilation
|
||||
|
||||
Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view:
|
||||
{kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable",
|
||||
"error"]}} for body-free rows with exact indices, addresses, procedure_addresses or
|
||||
statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to
|
||||
page one body.
|
||||
|
||||
## inspect_analysis_view: Evidence and transport
|
||||
|
||||
Returns artifact identity, parent Evidence ID, a distinct view digest, coverage,
|
||||
limitations and unknowns. Actual serialized size determines MCP transport admission;
|
||||
choose a smaller page or export the complete retained Evidence if needed.
|
||||
`;
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Use native-procedure-references to page retained procedure reference or unresolved-call rows. Endpoint kind selected-procedure refers to the summary procedure, null preserves the original null, and shared carries an index into items.endpoints, where each other procedure is stored once per page. Exact source_address and target_address filters apply to reference rows. For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. Use recorded-array with an exact raw_result object-member path to page a provider-recorded array when the producer supplies that path; rows keep their raw meaning and the parent retains coverage. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool hLine truncated
|
||||
'Inspect selected views of completed Evidence without rerunning analysis or reopening its source. Use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}; summary receipts name the complete parent in normalized_result.parent_evidence_id. Portable sources use {kind: "inline", evidence: <complete Evidence>} or {kind: "evidence-file", path: "<absolute path>", evidence_id?: "<id in bundle>"}. Source kind is required. Choose a view compatible with the parent operation. For format-specific kinds, selectors and examples, call search_guidance with query "inspect_analysis_view <format or view kind>", then read_guidance_section with the returned locator; hot/cold clients discover those named tools first. Pagination preserves parent identity, source coverage, limitations and unknowns; exhausting a page does not establish complete source discovery. Text offsets use UTF-16 code units where documented. Reduce the selected page or export complete retained Evidence if the reply exceeds the transport budget.',
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -100,7 +100,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
name: "search_guidance",
|
||||
...toolContractMetadata("search_guidance"),
|
||||
description:
|
||||
"Search only the installed REA workflow guidance. Every non-generic query word must occur in a section; sections holding every word in their own text rank before parents whose words are spread across subsections. Returns ranked sections, bounded pages, and locators for read_guidance_section. No target or provider is accessed.",
|
||||
"Search the installed REA workflow and tool guidance. Every non-generic query word must occur in a section; sections holding every word in their own text rank before parents whose words are spread across subsections. Returns ranked sections, bounded pages, and locators for read_guidance_section. No target or provider is accessed.",
|
||||
kind: "application",
|
||||
inputSchema: searchGuidanceInputSchema,
|
||||
outputSchema: searchGuidanceOutputSchema,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface RecordedMcpCatalog {
|
||||
readonly capabilities: ServerCapabilities;
|
||||
readonly instructions: string;
|
||||
readonly identity: ReturnType<typeof createCatalogIdentity>;
|
||||
/** Output projections precede the SDK's negotiated root-result codec. */
|
||||
readonly tools: readonly Tool[];
|
||||
/** Tool names derived from canonical Evidence-input declarations at build time. */
|
||||
readonly evidenceReferenceAlternatives: readonly string[];
|
||||
|
||||
@@ -81,16 +81,28 @@ const fixture = async (
|
||||
readonly maximumInputElements?: number;
|
||||
readonly evidenceReferenceAlternative?: boolean;
|
||||
readonly progressive?: boolean;
|
||||
readonly unionOutput?: boolean;
|
||||
readonly mode?: "legacy" | "auto";
|
||||
} = {},
|
||||
) => {
|
||||
const started = barrier();
|
||||
const calls = vi.fn<(n: number, context: ServerContext) => void>();
|
||||
const closed = vi.fn();
|
||||
let loads = 0;
|
||||
const canonicalOutput = options.unionOutput
|
||||
? z.union([output, z.strictObject({ message: z.string() })])
|
||||
: output;
|
||||
const recorded = {
|
||||
...catalog,
|
||||
tools: catalog.tools.map((tool) => ({
|
||||
...tool,
|
||||
outputSchema: z.toJSONSchema(canonicalOutput),
|
||||
})),
|
||||
};
|
||||
const server = new DeferredEvidenceMcpServer(
|
||||
options.evidenceReferenceAlternative
|
||||
? { ...catalog, evidenceReferenceAlternatives: ["fixture"] }
|
||||
: catalog,
|
||||
? { ...recorded, evidenceReferenceAlternatives: ["fixture"] }
|
||||
: recorded,
|
||||
new ToolResultDelivery(1024 * 1024),
|
||||
async (target) => {
|
||||
loads++;
|
||||
@@ -106,7 +118,7 @@ const fixture = async (
|
||||
target.materialize(() => {
|
||||
target.registerTool(
|
||||
"fixture",
|
||||
{ inputSchema: input, outputSchema: output },
|
||||
{ inputSchema: input, outputSchema: canonicalOutput },
|
||||
async (args, context) => {
|
||||
calls(args.n, context);
|
||||
await options.execute?.();
|
||||
@@ -146,7 +158,12 @@ const fixture = async (
|
||||
},
|
||||
options.maximumInputElements,
|
||||
);
|
||||
const client = new Client({ name: "deferred-fixture-client", version: "1" });
|
||||
const client = new Client(
|
||||
{ name: "deferred-fixture-client", version: "1" },
|
||||
options.mode === undefined
|
||||
? undefined
|
||||
: { versionNegotiation: { mode: options.mode } },
|
||||
);
|
||||
if (options.progressive) server.enableProgressiveCatalog();
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
@@ -300,6 +317,47 @@ it("preserves output validation after deferred registration", async () => {
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["legacy", "auto"] as const)(
|
||||
"canonically validates deferred object-union outputs over %s",
|
||||
async (mode) => {
|
||||
const valid = await fixture({ unionOutput: true, mode });
|
||||
const before = await valid.client.listTools();
|
||||
const schema = before.tools.find(
|
||||
({ name }) => name === "fixture",
|
||||
)?.outputSchema;
|
||||
if (schema === undefined) throw new Error("Missing union output schema");
|
||||
expect(schema).toHaveProperty("anyOf");
|
||||
expect(schema).not.toHaveProperty("properties.result");
|
||||
if (mode === "legacy") expect(schema.type).toBe("object");
|
||||
expect(valid.loads()).toBe(0);
|
||||
const success = await valid.client.callTool({
|
||||
name: "fixture",
|
||||
arguments: { n: 7 },
|
||||
});
|
||||
expect(success.isError).not.toBe(true);
|
||||
expect(success.structuredContent).toEqual({ n: 7 });
|
||||
expect(valid.calls).toHaveBeenCalledOnce();
|
||||
expect(await valid.client.listTools()).toEqual(before);
|
||||
|
||||
const invalid = await fixture({
|
||||
unionOutput: true,
|
||||
invalidOutput: true,
|
||||
mode,
|
||||
});
|
||||
const rejected = await invalid.client.callTool({
|
||||
name: "fixture",
|
||||
arguments: {},
|
||||
});
|
||||
// The handler actually returned malformed data. A server-side validation
|
||||
// error must arrive, rather than success or only a client-validator throw.
|
||||
expect(invalid.calls).toHaveBeenCalledOnce();
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(rejected.structuredContent).toBeUndefined();
|
||||
expect(invalid.loads()).toBe(1);
|
||||
expect(await invalid.client.listTools()).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
it("initializes through prompt retrieval or completion without an internal client", async () => {
|
||||
const f = await fixture();
|
||||
expect(
|
||||
|
||||
@@ -10,7 +10,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
const connect = async (
|
||||
options: { replyBytes?: number; bulk?: boolean } = {},
|
||||
options: { replyBytes?: number; bulk?: boolean; guidance?: boolean } = {},
|
||||
) => {
|
||||
const bulkNames = options.bulk
|
||||
? Array.from(
|
||||
@@ -44,6 +44,7 @@ const connect = async (
|
||||
"alpha_sqlite",
|
||||
"beta_sqlite",
|
||||
"excluded_sqlite",
|
||||
...(options.guidance ? ["inspect_analysis_view"] : []),
|
||||
...bulkNames,
|
||||
]) {
|
||||
const registration = server.registerTool(
|
||||
@@ -188,6 +189,23 @@ it("loads exact names atomically and emits one notification for a batch", async
|
||||
expect(f.changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finds deferred tool guidance keywords without advertising that guidance", async () => {
|
||||
const f = await connect({ guidance: true });
|
||||
const found = await f.discover({ query: "generic_method_issues" });
|
||||
expect(found.isError).not.toBe(true);
|
||||
expect(found.structuredContent).toMatchObject({
|
||||
total_matches: 1,
|
||||
tools: [{ name: "inspect_analysis_view" }],
|
||||
});
|
||||
const tool = (await f.client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_analysis_view",
|
||||
);
|
||||
expect(tool?.description).toBe(
|
||||
"inspect_analysis_view SQLite database inspection",
|
||||
);
|
||||
expect(f.changed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ names: [] },
|
||||
{ names: ["alpha_sqlite"], query: "sqlite" },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ownedJsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { encodeToolResult } from "./toolResultEncoding.js";
|
||||
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
|
||||
import { TOOL_GUIDANCE } from "../toolGuidance.js";
|
||||
import {
|
||||
DISCOVER_TOOLS,
|
||||
HOT_TOOL_NAMES,
|
||||
@@ -40,7 +41,7 @@ export class ProgressiveToolCatalog {
|
||||
registered,
|
||||
description: (registered.description ?? "").slice(0, 240),
|
||||
search:
|
||||
`${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""}`.toLowerCase(),
|
||||
`${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""} ${TOOL_GUIDANCE[name]?.content ?? ""}`.toLowerCase(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ANALYSIS_VIEW_GUIDANCE } from "./contracts/analysisViewGuidance.js";
|
||||
|
||||
/** Compiled documentation shares guidance retrieval and catalog search ownership. */
|
||||
export const TOOL_GUIDANCE: Readonly<
|
||||
Record<string, { readonly guide: string; readonly content: string }>
|
||||
> = {
|
||||
inspect_analysis_view: {
|
||||
guide: "tools/inspect_analysis_view.md",
|
||||
content: ANALYSIS_VIEW_GUIDANCE,
|
||||
},
|
||||
};
|
||||
@@ -9,8 +9,14 @@ import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.j
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js";
|
||||
import { TOOL_GUIDANCE } from "../../../src/toolGuidance.js";
|
||||
import { parseGuidanceSections } from "../../../src/application/GuidanceSections.js";
|
||||
import {
|
||||
searchGuidanceOutputSchema,
|
||||
readGuidanceSectionOutputSchema,
|
||||
} from "../../../src/contracts/guidanceContracts.js";
|
||||
|
||||
it("loads retained analysis views only when needed, preserving complete schemas, source facts and Evidence", async () => {
|
||||
const verifyGuidanceAndRetainedView = async (profile: "full" | "hot-cold") => {
|
||||
const root = await createTestTempDirectory("rea-progressive-api-");
|
||||
const path = join(root, "api.json");
|
||||
await writeFile(
|
||||
@@ -22,12 +28,14 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
}),
|
||||
);
|
||||
const { client } = await connectLocalToolsMcp({
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: profile,
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
});
|
||||
const names = async () =>
|
||||
(await client.listTools()).tools.map((tool) => tool.name);
|
||||
expect(await names()).not.toContain("inspect_analysis_view");
|
||||
expect((await names()).includes("inspect_analysis_view")).toBe(
|
||||
profile === "full",
|
||||
);
|
||||
const changed = vi.fn();
|
||||
client.setNotificationHandler("notifications/tools/list_changed", changed);
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
@@ -35,7 +43,10 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
return result;
|
||||
};
|
||||
await call("discover_tools", { names: ["inspect_api_contract"] });
|
||||
const load = async (names: string[]) => {
|
||||
if (profile === "hot-cold") await call("discover_tools", { names });
|
||||
};
|
||||
await load(["inspect_api_contract"]);
|
||||
expect(await names()).toContain("inspect_api_contract");
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
@@ -51,12 +62,53 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
kind: "api-contract",
|
||||
items: [{ method: "GET", path: "/items", state: "declared" }],
|
||||
});
|
||||
expect(await names()).not.toContain("inspect_analysis_view");
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect((await names()).includes("inspect_analysis_view")).toBe(
|
||||
profile === "full",
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 1 : 0);
|
||||
await rm(path);
|
||||
await call("discover_tools", {
|
||||
names: ["inspect_analysis_view", "export_evidence_bundle"],
|
||||
await load(["search_guidance", "read_guidance_section"]);
|
||||
const found = await call("search_guidance", {
|
||||
query: "inspect_analysis_view OpenAPI",
|
||||
});
|
||||
// Legacy MCP wraps root unions; auto-negotiated results use the object directly.
|
||||
const unwrap = (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "result" in value
|
||||
? value.result
|
||||
: value;
|
||||
const matches = searchGuidanceOutputSchema.parse(
|
||||
unwrap(found.structuredContent),
|
||||
);
|
||||
if (matches.status !== "matches") throw new Error("Expected tool guidance");
|
||||
const selectedGuide = matches.matches[0];
|
||||
const guidance = TOOL_GUIDANCE.inspect_analysis_view!;
|
||||
expect(selectedGuide?.guide).toBe(guidance.guide);
|
||||
const read = await call("read_guidance_section", {
|
||||
locator: selectedGuide?.locator,
|
||||
});
|
||||
const section = readGuidanceSectionOutputSchema.parse(
|
||||
unwrap(read.structuredContent),
|
||||
);
|
||||
if (section.status !== "section") throw new Error("Expected a guide section");
|
||||
const owner = parseGuidanceSections(
|
||||
new Map([[guidance.guide, guidance.content]]),
|
||||
)?.find(({ heading }) => heading === selectedGuide?.heading);
|
||||
expect(owner).toBeDefined();
|
||||
expect(section.content).toBe(owner?.content);
|
||||
if (profile === "hot-cold") {
|
||||
const miss = await call("discover_tools", {
|
||||
query: "inspect_analysis_view qzxv",
|
||||
});
|
||||
expect(miss.structuredContent).toMatchObject({ total_matches: 0 });
|
||||
// A keyword formerly present only in the oversized description still finds the view.
|
||||
const foundView = await call("discover_tools", {
|
||||
query: "generic_method_issues",
|
||||
});
|
||||
expect(foundView.structuredContent).toMatchObject({
|
||||
tools: [expect.objectContaining({ name: "inspect_analysis_view" })],
|
||||
});
|
||||
}
|
||||
await load(["inspect_analysis_view", "export_evidence_bundle"]);
|
||||
const loaded = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_analysis_view",
|
||||
);
|
||||
@@ -65,7 +117,10 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
"~standard"
|
||||
].jsonSchema.output({ target: "draft-2020-12" }),
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
expect(loaded?.description).toBe(
|
||||
toolContract("inspect_analysis_view").description,
|
||||
);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 4 : 0);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: selected.parent_evidence_id,
|
||||
@@ -93,6 +148,11 @@ it("loads retained analysis views only when needed, preserving complete schemas,
|
||||
expect(follow.structuredContent).toHaveProperty("evidence_links", [
|
||||
selected.parent_evidence_id,
|
||||
]);
|
||||
await call("discover_tools", { names: ["inspect_analysis_view"] });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await load(["inspect_analysis_view"]);
|
||||
expect(changed).toHaveBeenCalledTimes(profile === "hot-cold" ? 4 : 0);
|
||||
};
|
||||
|
||||
it.each(["full", "hot-cold"] as const)(
|
||||
"retrieves view guidance and retained facts with the %s catalog, preserving complete schemas and Evidence",
|
||||
verifyGuidanceAndRetainedView,
|
||||
);
|
||||
@@ -9,7 +9,7 @@ it("finds and retrieves the selected section from the generated package bundle",
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await searchGuidance(
|
||||
{ query: "exact JavaScript module" },
|
||||
{ query: "exact JavaScript module without repeating analysis" },
|
||||
resources,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
@@ -52,6 +52,47 @@ it("finds and retrieves the selected section from the generated package bundle",
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps workflow and selector guidance reachable for a broad module query", async () => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await searchGuidance(
|
||||
{ query: "exact JavaScript module" },
|
||||
resources,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
if (result.value.status !== "matches")
|
||||
throw new Error("Expected guidance matches");
|
||||
const workflow = result.value.matches.find(
|
||||
({ guide, heading }) =>
|
||||
guide === "references/javascript-applications.md" &&
|
||||
heading === "Read a module without repeating analysis",
|
||||
);
|
||||
expect(workflow).toBeDefined();
|
||||
const selectors = result.value.matches.find(
|
||||
({ guide, heading }) =>
|
||||
guide === "tools/inspect_analysis_view.md" &&
|
||||
heading === "inspect_analysis_view: JavaScript modules and facts",
|
||||
);
|
||||
expect(selectors).toBeDefined();
|
||||
if (selectors === undefined) throw new Error("Expected selector guidance");
|
||||
const read = await readGuidanceSection(
|
||||
{ locator: selectors.locator },
|
||||
resources,
|
||||
);
|
||||
if (!read.ok) throw read.error;
|
||||
expect(read.value).toMatchObject({
|
||||
status: "section",
|
||||
guide: selectors.guide,
|
||||
heading: selectors.heading,
|
||||
content: expect.stringContaining(
|
||||
"read module without repeating analysis",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("finds focused installed guidance for switching providers with annotations", async () => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,10 @@ import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import type { AnySchemaObject } from "ajv";
|
||||
import {
|
||||
searchGuidanceOutputSchema,
|
||||
readGuidanceSectionOutputSchema,
|
||||
} from "../../../src/contracts/guidanceContracts.js";
|
||||
|
||||
const connect = async (
|
||||
mode: "legacy" | "auto",
|
||||
@@ -277,7 +281,35 @@ it.each([
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
});
|
||||
const before = await client.listTools();
|
||||
expect(before.tools).toEqual(recorded.tools);
|
||||
// Compare independently composed canonical SDK registrations as well as
|
||||
// recorded artifacts. Listing every explicit family selects eager startup
|
||||
// with the same inventory, without target analysis or provider acquisition.
|
||||
const eager = await connect(mode, {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
REA_MCP_TOOL_FAMILIES: TOOL_FAMILY_DEFINITIONS.map(({ id }) => id).join(
|
||||
",",
|
||||
),
|
||||
});
|
||||
expect(before).toEqual(await eager.client.listTools());
|
||||
const withoutOutput = ({
|
||||
outputSchema: _output,
|
||||
...tool
|
||||
}: {
|
||||
outputSchema?: unknown;
|
||||
}) => tool;
|
||||
expect(before.tools.map(withoutOutput)).toEqual(
|
||||
recorded.tools.map(withoutOutput),
|
||||
);
|
||||
const outputs = new Map(
|
||||
(mode === "auto" ? recorded.tools : GENERATED_MCP_TOOL_CATALOG).map(
|
||||
(tool: { name: string; outputSchema?: unknown }) => [
|
||||
tool.name,
|
||||
tool.outputSchema,
|
||||
],
|
||||
),
|
||||
);
|
||||
for (const tool of before.tools)
|
||||
expect(tool.outputSchema, tool.name).toEqual(outputs.get(tool.name));
|
||||
const contractProjection = (tool: {
|
||||
name: string;
|
||||
title?: string | undefined;
|
||||
@@ -293,7 +325,7 @@ it.each([
|
||||
outputSchema: tool.outputSchema,
|
||||
annotations: tool.annotations,
|
||||
});
|
||||
if (profile === "full")
|
||||
if (profile === "full" && mode === "legacy")
|
||||
expect(
|
||||
new Map(
|
||||
before.tools.map((tool) => [tool.name, contractProjection(tool)]),
|
||||
@@ -358,6 +390,38 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["legacy", "auto"] as const)(
|
||||
"validates non-object-root guidance results from deferred startup over %s",
|
||||
async (mode) => {
|
||||
const { client, stderr } = await connect(mode);
|
||||
const before = await client.listTools();
|
||||
const unwrap = (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "result" in value
|
||||
? value.result
|
||||
: value;
|
||||
const found = await client.callTool({
|
||||
name: "search_guidance",
|
||||
arguments: { query: "exact JavaScript module", limit: 1 },
|
||||
});
|
||||
expect(found.isError).not.toBe(true);
|
||||
const matches = searchGuidanceOutputSchema.parse(
|
||||
unwrap(found.structuredContent),
|
||||
);
|
||||
if (matches.status !== "matches") throw new Error("Expected guide matches");
|
||||
const read = await client.callTool({
|
||||
name: "read_guidance_section",
|
||||
arguments: { locator: matches.matches[0]?.locator },
|
||||
});
|
||||
expect(read.isError).not.toBe(true);
|
||||
expect(
|
||||
readGuidanceSectionOutputSchema.parse(unwrap(read.structuredContent))
|
||||
.status,
|
||||
).toBe("section");
|
||||
expect(await client.listTools()).toEqual(before);
|
||||
expect(stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("composes a scoped registration instead of serving the recorded catalog", async () => {
|
||||
const { client, stderr } = await connect("legacy", {
|
||||
REA_MCP_TOOL_FAMILIES: "session",
|
||||
@@ -729,7 +793,12 @@ it.each([{}, { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" }])(
|
||||
({ outputSchema: _output, ...tool }: Record<string, unknown>) =>
|
||||
tool,
|
||||
)
|
||||
: catalog.tools;
|
||||
: catalog.tools.map((tool: { name: string }) => ({
|
||||
...tool,
|
||||
outputSchema: GENERATED_MCP_TOOL_CATALOG.find(
|
||||
({ name }) => name === tool.name,
|
||||
)?.outputSchema,
|
||||
}));
|
||||
expect(before.tools).toEqual(expected);
|
||||
const toolDefinition = before.tools.find(
|
||||
({ name }) => name === "list_unknowns",
|
||||
@@ -806,6 +875,7 @@ const RECORDER_RUNTIME_MODULES = [
|
||||
"contracts/promptContracts.js",
|
||||
"contracts/toolContracts.js",
|
||||
"domain/evidenceInputContract.js",
|
||||
"contracts/toolSchemaMetadata.js",
|
||||
] as const;
|
||||
|
||||
// Recorder cleanup tests replace every runtime import through their loader
|
||||
@@ -918,6 +988,8 @@ it.each([
|
||||
"/dist/contracts/toolContracts.js": "export const TOOL_CONTRACTS = [];",
|
||||
"/dist/domain/evidenceInputContract.js":
|
||||
"export const getEvidenceInputContract = () => ({ hasEvidenceReferenceAlternative: false });",
|
||||
"/dist/contracts/toolSchemaMetadata.js":
|
||||
"export const toolOutputSchemaWithMetadata = () => { throw new Error('No fixture tools'); };",
|
||||
};
|
||||
const clientModule = `data:text/javascript,${encodeURIComponent(`
|
||||
export class Client {
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import {
|
||||
Client,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
type Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { cp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterAll, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs";
|
||||
import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
readGuidanceSectionOutputSchema,
|
||||
searchGuidanceOutputSchema,
|
||||
} from "../../../src/contracts/guidanceContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { SERVER_START_FAILED } from "../../../src/main/messages.js";
|
||||
|
||||
const validation = createMcpSchemaValidation();
|
||||
afterAll(() => validation.clear());
|
||||
|
||||
type Configuration = {
|
||||
mode: "legacy" | "auto";
|
||||
input: "full" | "compact";
|
||||
output: "full" | "compact" | "off";
|
||||
catalog: "full" | "hot-cold";
|
||||
};
|
||||
|
||||
// Pairwise inputs across the protocol/output/catalog product: twelve independent
|
||||
// clients, not an exhaustive 24-cell product. Compact/off output intentionally
|
||||
// use eager composition; full output must remain deferred until a domain call.
|
||||
const matrix: Configuration[] = ["legacy", "auto"].flatMap((mode, m) =>
|
||||
["full", "compact", "off"].flatMap((output, o) =>
|
||||
["full", "hot-cold"].map((catalog, c) => ({
|
||||
mode: mode as Configuration["mode"],
|
||||
output: output as Configuration["output"],
|
||||
catalog: catalog as Configuration["catalog"],
|
||||
input: (m + o + c) % 2 === 0 ? "full" : "compact",
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
const lifecycleConfigurations = [
|
||||
{ mode: "legacy", input: "full", output: "full", catalog: "full" },
|
||||
{ mode: "auto", input: "compact", output: "full", catalog: "hot-cold" },
|
||||
] as const;
|
||||
|
||||
const connect = async (
|
||||
configuration: Configuration,
|
||||
nodeArguments: readonly string[] = [],
|
||||
entrypoint = resolve("scripts/rea.mjs"),
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-deferred-schema-profiles-");
|
||||
const log = join(root, "runtime-loaded");
|
||||
await writeFile(log, "");
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
import { appendFileSync } from "node:fs";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n"))
|
||||
appendFileSync(${JSON.stringify(log)}, "loaded\\n");
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [`--import=${hook}`, ...nodeArguments, entrypoint, "mcp"],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: root,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: configuration.input,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: configuration.output,
|
||||
REA_MCP_TOOL_CATALOG_PROFILE: configuration.catalog,
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const client = new Client(
|
||||
{ name: "deferred-schema-profile-client", version: "1" },
|
||||
{
|
||||
versionNegotiation: { mode: configuration.mode },
|
||||
jsonSchemaValidator: validation,
|
||||
},
|
||||
);
|
||||
let subscription: Awaited<ReturnType<Client["listen"]>> | undefined;
|
||||
const close = () =>
|
||||
closeVerifierResources([
|
||||
() => subscription?.close() ?? Promise.resolve(),
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
]);
|
||||
onTestFinished(close);
|
||||
await client.connect(transport);
|
||||
let definitions = new Map<string, Tool>();
|
||||
const list = async () => {
|
||||
const result = await client.listTools();
|
||||
definitions = new Map(result.tools.map((tool) => [tool.name, tool]));
|
||||
return result;
|
||||
};
|
||||
const definition = (name: string) => {
|
||||
const found = definitions.get(name);
|
||||
if (found === undefined) throw new Error(`Tool not advertised: ${name}`);
|
||||
return found;
|
||||
};
|
||||
const call = (
|
||||
name: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{
|
||||
toolDefinition: definition(name),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const changed = vi.fn();
|
||||
client.setNotificationHandler("notifications/tools/list_changed", changed);
|
||||
if (configuration.mode === "auto")
|
||||
subscription = await client.listen({ toolsListChanged: true });
|
||||
const load = async (names: string[]) => {
|
||||
if (configuration.catalog === "hot-cold") {
|
||||
const result = await call("discover_tools", { names });
|
||||
expect(result.isError).not.toBe(true);
|
||||
await list();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
tools: [...new Set(names)].map((name) => ({
|
||||
name,
|
||||
input_schema: definition(name).inputSchema,
|
||||
})),
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
root,
|
||||
client,
|
||||
list,
|
||||
definition,
|
||||
call,
|
||||
load,
|
||||
changed,
|
||||
close,
|
||||
runtimeLoaded: async () => (await readFile(log, "utf8")).length > 0,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapGuidance = (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "result" in value
|
||||
? value.result
|
||||
: value;
|
||||
|
||||
const verifyGuidance = async (
|
||||
f: Awaited<ReturnType<typeof connect>>,
|
||||
configuration: Configuration,
|
||||
) => {
|
||||
// The first domain call uses a root union, before any incidental materialization.
|
||||
const found = await f.call("search_guidance", {
|
||||
query: "exact JavaScript module",
|
||||
limit: 1,
|
||||
});
|
||||
expect(found.isError).not.toBe(true);
|
||||
expect(found.structuredContent).toHaveProperty(
|
||||
configuration.mode === "legacy" ? "result.status" : "status",
|
||||
"matches",
|
||||
);
|
||||
expect(
|
||||
searchGuidanceOutputSchema.parse(unwrapGuidance(found.structuredContent))
|
||||
.status,
|
||||
).toBe("matches");
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
|
||||
const missing = await f.call("search_guidance", {
|
||||
query: "unmatched_qzxv",
|
||||
});
|
||||
expect(
|
||||
searchGuidanceOutputSchema.parse(unwrapGuidance(missing.structuredContent))
|
||||
.status,
|
||||
).toBe("no_matches");
|
||||
const badGuidance = await f.call("search_guidance", { query: 17 });
|
||||
expect(parseMcpToolError(badGuidance).error.code).toBe("invalid_request");
|
||||
expect(badGuidance.structuredContent).toBeUndefined();
|
||||
|
||||
const guide = await f.call("search_guidance", {
|
||||
query: "inspect_analysis_view OpenAPI",
|
||||
limit: 1,
|
||||
});
|
||||
const matches = searchGuidanceOutputSchema.parse(
|
||||
unwrapGuidance(guide.structuredContent),
|
||||
);
|
||||
if (matches.status !== "matches")
|
||||
throw new Error("Expected compiled tool guidance");
|
||||
expect(matches.matches[0]?.guide).toBe("tools/inspect_analysis_view.md");
|
||||
const read = await f.call("read_guidance_section", {
|
||||
locator: matches.matches[0]?.locator,
|
||||
});
|
||||
expect(read.isError).not.toBe(true);
|
||||
expect(
|
||||
readGuidanceSectionOutputSchema.parse(
|
||||
unwrapGuidance(read.structuredContent),
|
||||
).status,
|
||||
).toBe("section");
|
||||
};
|
||||
|
||||
it.each(matrix)(
|
||||
"preserves schemas, guidance and retained Evidence over $mode with $input inputs, $output outputs and $catalog catalog",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
const initial = await f.list();
|
||||
expect(f.client.getProtocolEra()).toBe(
|
||||
configuration.mode === "legacy" ? "legacy" : "modern",
|
||||
);
|
||||
const eager = configuration.output !== "full";
|
||||
expect(await f.runtimeLoaded()).toBe(eager);
|
||||
if (configuration.catalog === "hot-cold") {
|
||||
expect(new Set(initial.tools.map(({ name }) => name))).toEqual(
|
||||
new Set([...HOT_TOOL_NAMES, "discover_tools"]),
|
||||
);
|
||||
} else {
|
||||
expect(initial.tools.map(({ name }) => name)).toContain(
|
||||
"inspect_analysis_view",
|
||||
);
|
||||
}
|
||||
await f.load([
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(eager);
|
||||
const before = await f.list();
|
||||
for (const name of [
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
]) {
|
||||
const definition = f.definition(name);
|
||||
expect(
|
||||
validation.assertions.validateSchema(definition.inputSchema),
|
||||
name,
|
||||
).toBe(true);
|
||||
if (configuration.output === "off")
|
||||
expect(definition.outputSchema, name).toBeUndefined();
|
||||
else {
|
||||
if (definition.outputSchema === undefined)
|
||||
throw new Error(`Missing output schema: ${name}`);
|
||||
expect(
|
||||
validation.assertions.validateSchema(definition.outputSchema),
|
||||
name,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(before);
|
||||
|
||||
const path = join(f.root, "api.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Schema matrix", version: "1" },
|
||||
paths: {
|
||||
"/items": { get: { operationId: "listItems" } },
|
||||
"/items/{id}": { delete: { operationId: "removeItem" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const produced = await f.call("inspect_api_contract", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(produced.isError).not.toBe(true);
|
||||
const parent = parseEvidence(produced.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(parent.normalized_result).operations,
|
||||
).toMatchObject([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
declared_id: "listItems",
|
||||
source_pointer: "/paths/~1items/get",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/items/{id}",
|
||||
declared_id: "removeItem",
|
||||
source_pointer: "/paths/~1items~1{id}/delete",
|
||||
},
|
||||
]);
|
||||
await rm(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
paths: ["/items"],
|
||||
fields: ["method", "path"],
|
||||
limit: 1,
|
||||
};
|
||||
const malformed = { source, view: { ...view, offset: -1 } };
|
||||
expect(
|
||||
toolContract("inspect_analysis_view").inputSchema.safeParse(malformed)
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
f.definition("inspect_analysis_view").inputSchema,
|
||||
{ source, view },
|
||||
),
|
||||
).toBe(true);
|
||||
// Compact advertisements may safely retain more canonical constraints.
|
||||
// Exact rejection belongs to the full projection and the server parser.
|
||||
if (configuration.input === "full")
|
||||
expect(
|
||||
validation.assertions.validate(
|
||||
f.definition("inspect_analysis_view").inputSchema,
|
||||
malformed,
|
||||
),
|
||||
).toBe(false);
|
||||
const rejected = await f.call("inspect_analysis_view", malformed);
|
||||
expect(parseMcpToolError(rejected).error.code).toBe("invalid_request");
|
||||
expect(rejected.structuredContent).toBeUndefined();
|
||||
const selected = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(selected.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(selected.structuredContent);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(evidence.normalized_result),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/items", state: "declared" }],
|
||||
});
|
||||
expect(evidence.evidence_links).toEqual([parent.evidence_id]);
|
||||
const repeated = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(parseEvidence(repeated.structuredContent)).toEqual(evidence);
|
||||
const unknown = await f.call("inspect_analysis_view", {
|
||||
source: { ...source, evidence_id: `ev_${"b".repeat(64)}` },
|
||||
view,
|
||||
});
|
||||
expect(parseMcpToolError(unknown).error.code).toBe(
|
||||
"evidence_integrity_mismatch",
|
||||
);
|
||||
expect(unknown.structuredContent).toBeUndefined();
|
||||
const notifications = f.changed.mock.calls.length;
|
||||
await f.load([
|
||||
"inspect_analysis_view",
|
||||
"search_guidance",
|
||||
"inspect_analysis_view",
|
||||
]);
|
||||
expect(f.changed).toHaveBeenCalledTimes(notifications);
|
||||
expect(notifications).toBe(configuration.catalog === "hot-cold" ? 1 : 0);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
const verifyReconnect = async ({
|
||||
configuration,
|
||||
names,
|
||||
parent,
|
||||
view,
|
||||
}: {
|
||||
configuration: Configuration;
|
||||
names: string[];
|
||||
parent: ReturnType<typeof parseEvidence>;
|
||||
view: Record<string, unknown>;
|
||||
}) => {
|
||||
const source = { kind: "retained-evidence", evidence_id: parent.evidence_id };
|
||||
const next = await connect(configuration);
|
||||
const fresh = await next.list();
|
||||
expect(fresh.tools.map(({ name }) => name)).toEqual(
|
||||
configuration.catalog === "hot-cold"
|
||||
? names.filter(
|
||||
(name) => HOT_TOOL_NAMES.has(name) || name === "discover_tools",
|
||||
)
|
||||
: names,
|
||||
);
|
||||
expect(await next.runtimeLoaded()).toBe(false);
|
||||
await next.load(["inspect_analysis_view"]);
|
||||
const expired = await next.call("inspect_analysis_view", {
|
||||
source,
|
||||
view,
|
||||
});
|
||||
expect(parseMcpToolError(expired).error.code).toBe(
|
||||
"evidence_integrity_mismatch",
|
||||
);
|
||||
const portable = await next.call("inspect_analysis_view", {
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view,
|
||||
});
|
||||
expect(portable.isError).not.toBe(true);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(
|
||||
parseEvidence(portable.structuredContent).normalized_result,
|
||||
),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/peer" }],
|
||||
});
|
||||
expect(next.stderr()).toBe("");
|
||||
};
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"keeps a peer callable after cancellation during $mode/$catalog materialization, then reconnects with fresh visibility and Evidence",
|
||||
async (configuration) => {
|
||||
const control = await createTestTempDirectory(
|
||||
"rea-materialization-cancellation-",
|
||||
);
|
||||
const started = join(control, "started");
|
||||
const release = join(control, "release");
|
||||
// Hold the real lazy module at import time. Every request still passes
|
||||
// through the production SDK transport, registration and canonical parser.
|
||||
const gate = `await (async () => {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(${JSON.stringify(started)}, "started");
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
try { await fs.access(${JSON.stringify(release)}); return; } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error("Fixture materialization was not released");
|
||||
})();\n`;
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n")) {
|
||||
return { ...loaded, source: ${JSON.stringify(gate)} + loaded.source.toString() };
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const f = await connect(configuration, [`--import=${hook}`]);
|
||||
await f.list();
|
||||
await f.load([
|
||||
"inspect_api_contract",
|
||||
"inspect_analysis_view",
|
||||
"export_evidence_bundle",
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const before = await f.list();
|
||||
const path = join(control, "peer.json");
|
||||
const cancelledPath = join(control, "cancelled.json");
|
||||
const api = (id: string) =>
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Cancellation fixture", version: "1" },
|
||||
paths: { "/peer": { get: { operationId: id } } },
|
||||
});
|
||||
await writeFile(path, api("retainedPeer"));
|
||||
await writeFile(cancelledPath, api("cancelledMustNotRun"));
|
||||
const controller = new AbortController();
|
||||
const reason = new SdkError(
|
||||
SdkErrorCode.RequestTimeout,
|
||||
"Cancelled during shared deferred initialization",
|
||||
);
|
||||
const cancelled = f.call(
|
||||
"inspect_api_contract",
|
||||
{ path: cancelledPath, detail: "complete" },
|
||||
controller.signal,
|
||||
);
|
||||
const rejected = expect(cancelled).rejects.toBe(reason);
|
||||
const pending: Promise<unknown>[] = [cancelled, rejected];
|
||||
try {
|
||||
const acknowledged = vi.waitFor(
|
||||
async () => expect(await readFile(started, "utf8")).toBe("started"),
|
||||
{ timeout: 2000, interval: 10 },
|
||||
);
|
||||
pending.push(acknowledged);
|
||||
await awaitAcknowledgement(
|
||||
acknowledged,
|
||||
cancelled,
|
||||
"public deferred materialization",
|
||||
);
|
||||
const peer = f.call("inspect_api_contract", { path, detail: "complete" });
|
||||
pending.push(peer);
|
||||
controller.abort(reason);
|
||||
await rejected;
|
||||
// A round trip while import is held also proves the catalog/protocol
|
||||
// remain responsive after sending the cancellation notification.
|
||||
expect(await f.list()).toEqual(before);
|
||||
await writeFile(release, "release");
|
||||
const result = await peer;
|
||||
expect(result.isError).not.toBe(true);
|
||||
const parent = parseEvidence(result.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(parent.normalized_result).operations,
|
||||
).toMatchObject([
|
||||
{ path: "/peer", method: "GET", declared_id: "retainedPeer" },
|
||||
]);
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(await f.list()).toEqual(before);
|
||||
const exportPath = join(control, "bundle.json");
|
||||
expect(
|
||||
(await f.call("export_evidence_bundle", { path: exportPath })).isError,
|
||||
).not.toBe(true);
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
JSON.parse(await readFile(exportPath, "utf8")),
|
||||
);
|
||||
expect(
|
||||
bundle.records
|
||||
.filter(({ operation }) => operation === "inspect_api_contract")
|
||||
.map(({ evidence_id }) => evidence_id),
|
||||
).toEqual([parent.evidence_id]);
|
||||
await rm(path);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: parent.evidence_id,
|
||||
};
|
||||
const view = {
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
fields: ["method", "path"],
|
||||
limit: 1,
|
||||
};
|
||||
const selected = await f.call("inspect_analysis_view", { source, view });
|
||||
expect(selected.isError).not.toBe(true);
|
||||
expect(
|
||||
analysisViewResultSchema.parse(
|
||||
parseEvidence(selected.structuredContent).normalized_result,
|
||||
),
|
||||
).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
items: [{ method: "GET", path: "/peer" }],
|
||||
});
|
||||
await f.close();
|
||||
|
||||
await verifyReconnect({
|
||||
configuration,
|
||||
names: before.tools.map(({ name }) => name),
|
||||
parent,
|
||||
view,
|
||||
});
|
||||
expect(f.stderr()).toBe("");
|
||||
} finally {
|
||||
controller.abort(reason);
|
||||
await writeFile(release, "release");
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"recovers from a malformed first domain call over $mode/$catalog without replacing advertised schemas",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const malformed = await f.call("search_guidance", { query: 17 });
|
||||
expect(parseMcpToolError(malformed).error.code).toBe("invalid_request");
|
||||
expect(malformed.structuredContent).toBeUndefined();
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"keeps discovery responsive and rejects domain calls after $mode/$catalog materialization fails",
|
||||
async (configuration) => {
|
||||
// Fault the lazy module's evaluation, preserving its real imports/exports.
|
||||
// Transport startup and discovery must still use the production server.
|
||||
const hook = `data:text/javascript,${encodeURIComponent(`
|
||||
import { registerHooks } from "node:module";
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const loaded = next(url, context);
|
||||
if (url.endsWith("/dist/main/eagerRuntime.js") ||
|
||||
loaded.source?.toString().includes("//#region dist/main/eagerRuntime.js\\n"))
|
||||
return { ...loaded, source: 'throw new Error("PRIVATE fixture initialization failure");\\n' + loaded.source.toString() };
|
||||
return loaded;
|
||||
}
|
||||
});
|
||||
`)}`;
|
||||
const f = await connect(configuration, [`--import=${hook}`]);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
for (const query of ["inspect_analysis_view OpenAPI", "unmatched_qzxv"]) {
|
||||
const result = await f.call("search_guidance", { query });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toBeUndefined();
|
||||
expect(JSON.stringify(result)).toContain(SERVER_START_FAILED);
|
||||
expect(JSON.stringify(result)).not.toContain("PRIVATE");
|
||||
}
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"materializes through a prompt operation over $mode/$catalog and preserves schemas and live Evidence completion",
|
||||
async (configuration) => {
|
||||
const f = await connect(configuration);
|
||||
await f.list();
|
||||
await f.load([
|
||||
"inspect_api_contract",
|
||||
"export_evidence_bundle",
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
]);
|
||||
const toolsBefore = await f.list();
|
||||
const promptsBefore = await f.client.listPrompts();
|
||||
expect(promptsBefore.prompts.map(({ name }) => name)).toContain(
|
||||
"verify_reconstruction",
|
||||
);
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const complete = () =>
|
||||
f.client.complete({
|
||||
ref: { type: "ref/prompt", name: "verify_reconstruction" },
|
||||
argument: { name: "comparison_evidence_id", value: "ev_" },
|
||||
});
|
||||
// Use retrieval first on legacy, completion first on modern; neither
|
||||
// operation should execute an analysis or create Evidence observations.
|
||||
if (configuration.mode === "auto")
|
||||
expect((await complete()).completion.values).toEqual([]);
|
||||
const prompt = await f.client.getPrompt({
|
||||
name: "investigate_feature",
|
||||
arguments: { feature: "declared GET /items" },
|
||||
});
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(prompt.messages).toHaveLength(1);
|
||||
expect(prompt.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: expect.stringContaining('"feature":"declared GET /items"'),
|
||||
},
|
||||
});
|
||||
if (configuration.mode === "legacy")
|
||||
expect((await complete()).completion.values).toEqual([]);
|
||||
await expect(
|
||||
f.client.getPrompt({
|
||||
name: "investigate_feature",
|
||||
arguments: { feature: " " },
|
||||
}),
|
||||
).rejects.toThrow(/Invalid arguments/u);
|
||||
expect(await f.client.listPrompts()).toEqual(promptsBefore);
|
||||
expect(await f.list()).toEqual(toolsBefore);
|
||||
const exportPath = join(f.root, "prompt-only.json");
|
||||
expect(
|
||||
(await f.call("export_evidence_bundle", { path: exportPath })).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
evidenceBundleSchema.parse(JSON.parse(await readFile(exportPath, "utf8")))
|
||||
.records,
|
||||
).toEqual([]);
|
||||
const path = join(f.root, "completion.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Prompt completion", version: "1" },
|
||||
paths: { "/items": { get: { operationId: "listItems" } } },
|
||||
}),
|
||||
);
|
||||
const result = await f.call("inspect_api_contract", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(result.structuredContent);
|
||||
expect(
|
||||
apiContractSchema.parse(evidence.normalized_result).operations,
|
||||
).toMatchObject([{ method: "GET", path: "/items" }]);
|
||||
expect((await complete()).completion.values).toEqual([
|
||||
evidence.evidence_id,
|
||||
]);
|
||||
await verifyGuidance(f, configuration);
|
||||
expect(await f.list()).toEqual(toolsBefore);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lifecycleConfigurations)(
|
||||
"validates stale and unavailable guidance replies and recovers after bundle repair over $mode/$catalog",
|
||||
async (configuration) => {
|
||||
const runtime = await createTestTempDirectory(
|
||||
"rea-guidance-failure-runtime-",
|
||||
);
|
||||
// Exercise the shipped dispatcher and runtime with an independently
|
||||
// editable guide bundle. Only locked dependencies are linked to the build.
|
||||
for (const path of ["dist", "skills", "scripts", "package.json"])
|
||||
await cp(resolve(path), join(runtime, path), { recursive: true });
|
||||
await symlink(
|
||||
resolve("node_modules"),
|
||||
join(runtime, "node_modules"),
|
||||
"junction",
|
||||
);
|
||||
const guidePath = join(
|
||||
runtime,
|
||||
"skills/reverse-engineer-anything/SKILL.md",
|
||||
);
|
||||
const original = await readFile(guidePath);
|
||||
const f = await connect(
|
||||
configuration,
|
||||
[],
|
||||
join(runtime, "scripts/rea.mjs"),
|
||||
);
|
||||
await f.list();
|
||||
await f.load(["search_guidance", "read_guidance_section"]);
|
||||
const before = await f.list();
|
||||
expect(await f.runtimeLoaded()).toBe(false);
|
||||
const search = async (query: string, cursor?: string) => {
|
||||
const result = await f.call("search_guidance", {
|
||||
query,
|
||||
limit: 1,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
return searchGuidanceOutputSchema.parse(
|
||||
unwrapGuidance(result.structuredContent),
|
||||
);
|
||||
};
|
||||
const found = await search("inspect_analysis_view OpenAPI");
|
||||
if (found.status !== "matches" || found.matches[0] === undefined)
|
||||
throw new Error("Expected a compiled guidance locator");
|
||||
expect(found.matches[0].guide).toBe("tools/inspect_analysis_view.md");
|
||||
const locator = found.matches[0].locator;
|
||||
const page = await search("inspect_analysis_view");
|
||||
if (page.status !== "matches" || page.next_cursor === undefined)
|
||||
throw new Error("Expected a guidance continuation token");
|
||||
const read = async () => {
|
||||
const result = await f.call("read_guidance_section", { locator });
|
||||
expect(result.isError).not.toBe(true);
|
||||
return readGuidanceSectionOutputSchema.parse(
|
||||
unwrapGuidance(result.structuredContent),
|
||||
);
|
||||
};
|
||||
await writeFile(
|
||||
guidePath,
|
||||
Buffer.concat([
|
||||
original,
|
||||
Buffer.from("\n<!-- bundle drift fixture -->\n"),
|
||||
]),
|
||||
);
|
||||
expect((await read()).status).toBe("stale_locator");
|
||||
expect(
|
||||
(await search("inspect_analysis_view", page.next_cursor)).status,
|
||||
).toBe("stale_continuation");
|
||||
const invalid = await f.call("read_guidance_section", {
|
||||
locator: "../../outside.md",
|
||||
});
|
||||
expect(parseMcpToolError(invalid).error.code).toBe("invalid_request");
|
||||
expect(invalid.structuredContent).toBeUndefined();
|
||||
await rm(guidePath);
|
||||
expect((await search("inspect_analysis_view OpenAPI")).status).toBe(
|
||||
"bundle_unavailable",
|
||||
);
|
||||
expect((await read()).status).toBe("bundle_unavailable");
|
||||
await writeFile(guidePath, original);
|
||||
expect(await search("inspect_analysis_view OpenAPI")).toMatchObject({
|
||||
status: "matches",
|
||||
bundle_sha256: found.bundle_sha256,
|
||||
});
|
||||
expect(await read()).toMatchObject({
|
||||
status: "section",
|
||||
guide: "tools/inspect_analysis_view.md",
|
||||
locator,
|
||||
content: expect.stringContaining("api-contract"),
|
||||
});
|
||||
expect(await f.runtimeLoaded()).toBe(true);
|
||||
expect(await f.list()).toEqual(before);
|
||||
expect(f.stderr()).toBe("");
|
||||
},
|
||||
);
|
||||
@@ -12,14 +12,25 @@ import {
|
||||
} from "../../support/mcp/streamableHttpProcess.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
|
||||
import { searchGuidanceOutputSchema } from "../../../src/contracts/guidanceContracts.js";
|
||||
|
||||
const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
|
||||
|
||||
const openSession = async (url: URL, name: string) => {
|
||||
const openSession = async (
|
||||
url: URL,
|
||||
name: string,
|
||||
mode: "legacy" | "auto" = "legacy",
|
||||
) => {
|
||||
const transport = new StreamableHTTPClientTransport(url);
|
||||
const client = new Client({ name, version: "1" });
|
||||
const client = new Client(
|
||||
{ name, version: "1" },
|
||||
{ versionNegotiation: { mode } },
|
||||
);
|
||||
onTestFinished(() => client.close());
|
||||
await client.connect(transport);
|
||||
// HTTP deliberately composes the legacy server; auto must fall back before
|
||||
// using its session lifecycle and unsolicited catalog-change notifications.
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe("2025-11-25");
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
@@ -46,57 +57,99 @@ const expectIdentity = async ({
|
||||
});
|
||||
};
|
||||
|
||||
describe("rea mcp --http", () => {
|
||||
processTest(
|
||||
"keeps hot/cold visibility within its HTTP session and refreshes named contracts",
|
||||
async ({ processes }) => {
|
||||
const started = await startListeningProcess(
|
||||
processes,
|
||||
["mcp", "--http", "--port", "0"],
|
||||
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
|
||||
);
|
||||
const first = await openSession(started.url, "hot-cold-first");
|
||||
const initialNames = (await first.client.listTools()).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
expect(initialNames).not.toContain("inspect_analysis_view");
|
||||
expect(initialNames).not.toContain("list_unknowns");
|
||||
let changed = 0;
|
||||
first.client.setNotificationHandler(
|
||||
"notifications/tools/list_changed",
|
||||
() => {
|
||||
changed++;
|
||||
},
|
||||
);
|
||||
const discovered = await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["list_unknowns"] },
|
||||
});
|
||||
expect(discovered.isError).not.toBe(true);
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
first.client,
|
||||
"list_unknowns",
|
||||
);
|
||||
expect(changed).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await first.client.callTool(
|
||||
{ name: "list_unknowns", arguments: {} },
|
||||
{ toolDefinition },
|
||||
)
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
await first.transport.terminateSession();
|
||||
await first.client.close();
|
||||
const second = await openSession(started.url, "hot-cold-second");
|
||||
expect(
|
||||
(await second.client.listTools()).tools.map(({ name }) => name),
|
||||
).toEqual(initialNames);
|
||||
await expectIdentity(second);
|
||||
await second.transport.terminateSession();
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
/** Use the exact refreshed definition; root-union framing belongs to the SDK. */
|
||||
const searchGuidance = async (client: Client, query: string) => {
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"search_guidance",
|
||||
);
|
||||
const result = await client.callTool(
|
||||
{ name: "search_guidance", arguments: { query, limit: 1 } },
|
||||
{ toolDefinition },
|
||||
);
|
||||
expect(result.isError).not.toBe(true);
|
||||
const content = result.structuredContent;
|
||||
return searchGuidanceOutputSchema.parse(
|
||||
typeof content === "object" && content !== null && "result" in content
|
||||
? content.result
|
||||
: content,
|
||||
);
|
||||
};
|
||||
|
||||
describe("rea mcp --http", () => {
|
||||
for (const mode of ["legacy", "auto"] as const)
|
||||
processTest(
|
||||
`${mode} keeps hot/cold visibility within its HTTP session and refreshes named contracts`,
|
||||
async ({ processes }) => {
|
||||
const started = await startListeningProcess(
|
||||
processes,
|
||||
["mcp", "--http", "--port", "0"],
|
||||
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
|
||||
);
|
||||
const first = await openSession(started.url, "hot-cold-first", mode);
|
||||
const initialNames = (await first.client.listTools()).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
expect(initialNames).not.toContain("inspect_analysis_view");
|
||||
expect(initialNames).not.toContain("list_unknowns");
|
||||
let changed = 0;
|
||||
first.client.setNotificationHandler(
|
||||
"notifications/tools/list_changed",
|
||||
() => {
|
||||
changed++;
|
||||
},
|
||||
);
|
||||
const discovered = await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["list_unknowns", "search_guidance"] },
|
||||
});
|
||||
expect(discovered.isError).not.toBe(true);
|
||||
const { toolDefinition } = await advertisedMcpToolSchemas(
|
||||
first.client,
|
||||
"list_unknowns",
|
||||
);
|
||||
expect(changed).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await first.client.callTool(
|
||||
{ name: "list_unknowns", arguments: {} },
|
||||
{ toolDefinition },
|
||||
)
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
await searchGuidance(first.client, "inspect_analysis_view OpenAPI"),
|
||||
).toMatchObject({
|
||||
status: "matches",
|
||||
matches: [{ guide: "tools/inspect_analysis_view.md" }],
|
||||
});
|
||||
await first.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["search_guidance", "list_unknowns"] },
|
||||
});
|
||||
expect(changed).toBe(1);
|
||||
await first.transport.terminateSession();
|
||||
await first.client.close();
|
||||
const second = await openSession(started.url, "hot-cold-second", mode);
|
||||
expect(
|
||||
(await second.client.listTools()).tools.map(({ name }) => name),
|
||||
).toEqual(initialNames);
|
||||
expect(
|
||||
(
|
||||
await second.client.callTool({
|
||||
name: "discover_tools",
|
||||
arguments: { names: ["search_guidance"] },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
(await searchGuidance(second.client, "unmatched_qzxv")).status,
|
||||
).toBe("no_matches");
|
||||
await expectIdentity(second);
|
||||
await second.transport.terminateSession();
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
processTest(
|
||||
"serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients",
|
||||
|
||||
Reference in new issue
Block a user