inventory-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@inventory-mcp查一下光模块SR4的库存,需要100根"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Inventory MCP
Reads the dozens of Feishu source tables in the solution package (currently 31), filters, dedupes, normalizes, and aggregates them into thirteen tools for the agent to call; imports into the Feishu ledger weekly.
Zero dependencies: MCP runs JSON-RPC over stdio, no node_modules, only needs node on the target machine.
Install
Click "MCP List → Edit Config" in WorkBuddy, or edit ~/.workbuddy/mcp.json directly:
{
"mcpServers": {
"inventory": {
"command": "node",
"args": ["/path/to/inventory-mcp/server.mjs"]
}
}
}Claude Desktop or other MCP clients work the same way, same config shape.
The thirteen tools
Tool | Parameters (all optional) | What it returns |
| Asset type / Model or Slot / Models / Brand / Warehouse / How many needed / Detail rows / Need available qty / Must have file | Matching material keys, counts for each, subtotals by warehouse (with tiers), grand total, the closing line (only when "how many needed" is given), automatically relaxes one criterion when insufficient and brings candidates along; echoes "what was queried this time" (parsed slots, for copy-paste in the next round) |
| Asset type / Top N | "Model × Warehouse" matrix + totals per warehouse |
| Model / Asset type / How many needed / Warehouse / How many per tier | Four tiers by "how much the machine can prove": exact / rule-based / questionable / similar, plus a "combination" suggestion; human-approved candidates get a "human-approved" note |
| Model / Brand / Warehouse / Asset type / Material key / Max rows / Must have file | Serial numbers one by one. If more than 50, writes a CSV and returns only the path, see below |
| Compared to which day / Asset type / Warehouse / Top N | What came in and went out vs. the weekly baseline. Reports true SN-level in/out first, key-level changes are demoted, see below |
| Asset type / Warehouse / Field / Whether to include per-table detail | Which table and column these numbers come from. Read-only on the solution package, no network, see below |
| Asset type (required) / With slots / With counts | All canonical spellings of this category in stock + which brands have how much stock each (closed list). Pick from it when the customer's spelling isn't standard, see below |
| Asset type / Only changed | Which values each filter rule recognizes, how many rows each, and what changed vs. last time. This is raw material, not conclusions, see below |
| Work order no. / Project / Parts requirement | After the requirement is satisfied, registers parts into the reservation ledger (closing step of the loop). Pass the parts rows from cmdb "query work order" (only memory/disk/optical module/NIC). Each row is split by available qty: satisfied → reserved/approving (linked to real keys), shortfall → purchasing in transit (create/reuse same-model virtual pending-purchase key). Dry run by default returns plan + report; actually write with |
| Work order no. / Project / Parts requirement | After matching, assembles the notification, split by recipient and returned (not actually sent): satisfied → a section for "asset manager (ledger owner)", out of stock/pending → a section for "procurement (procurement contact)". You copy-paste into the corresponding Feishu chat and send it yourself — the sender is you, bypassing bot availability scope/tenant policy; "copy → send" is the review step. Out of stock = matched inventory but insufficient; pending = model didn't match inventory (please check spelling), listed separately. Read-only on the ledger, no messages sent, no message permission needed. Why not bot direct-send: tested, user-identity sends are blocked by tenant policy (230027), bot DMs to others require that person to be in the app's availability scope (230013), bot sending to a group requires the bot to be in the group first (230002) — returning text for you to send yourself bypasses all of these |
| Confirmation ticket / User choice | Refreshes ledger + registry + in-transit daily, produces a "should have changed but didn't" red-check self-check receipt (not just a timestamp). Two-step confirmation (actual write is batch-writing ledger/registry): ① call with no args → dry run produces receipt (ledger added/updated/zeroed, registry new keys issued, in-transit remaining count) + confirmation ticket, nothing written; ② you must first call AskUserQuestion to show the receipt to the user and ask whether to actually write, then re-call with ticket + user choice to write, then run the full red-check after writing. Uses Feishu, not the corporate network; work order sync needs the corporate network, skipped and flagged in the receipt. Logic in |
| Confirmation ticket / User choice / Acknowledge filters | Runs every Thursday (superset of daily update): re-reads source tables → compares against last Thursday's baseline (week-over-week: in/out, which keys truly zeroed → reservations left dangling, in-transit arrived or not) → brand pending → stores this week's baseline → writes ledger. Two-step confirmation same as daily update (dry run produces receipt + ticket → AskUserQuestion → actually write). Hard rule: if any source table can't be read, don't store baseline, don't write ledger. Logic in |
| All required: Asset type / What was wanted / What was chosen / Conclusion / Basis / Who decided | Records human decisions on alternatives on the spot so next time there's no need to ask again. The only tool that writes "judgments" to disk, see below |
Parameters are always flat, arrays hold only strings, no nested objects — cheap models have poor tolerance for nested structures.
Multi-category queries in one go are written as models: ["opticalModule:SR4","disk:960G"], still flat string arrays.
Renamed parameters are rejected at the protocol layer, not silently ignored. The "quantity" of Find Alternatives was merged into
"how many needed" on 2026-08-14 (same name as Check Inventory — two names for the same thing, the model will eventually send the wrong one).
Silent ignoring looks like: the model gets a seemingly normal response, just the "is it enough" block is entirely missing —
a missing block with no error. So the old name gets a -32602 back to make it resend with a different name.
How models are matched: via slots, there is no string-matching path
"Model" is not substring matching. There was one real incident: querying OSFP112-800G-2*DR4-SM1310 with substring matching hit 0,
while the inventory had 18,000 units of OSFP112-RHS-800G-2*DR4-SM1310 — the extra RHS segment in the middle broke the whole string,
and the model reported "this item isn't in stock" based on that. Now both sides are parsed into form/rate/std/media/wave and compared field by field,
even bare keywords work (SR4 parses to {std:SR4, media:MM, wave:850}, exactly hitting 35 keys).
The pipeline is fixed:
模型(对着 instructions 里两张常驻表:槽位词表 351 token + 品牌名单 93 token)
定资产类型(必填,机器不猜)→ 把客户的乱写法翻成槽位 → 挑品牌标准名
↓
查库存 → 校验槽位值在词表里(不在当场报错并列出合法值)
→ 逐槽位相等才算命中 → 品牌精确匹配(不是包含)
↓
命中 0,或者命中了但不够「要几根」
→ 自动放宽收益最大的那一项,把明细直接带回来(不让模型再问一轮 ≈ 8,000 token)
→ 「没查到」+ 你的槽位是什么 + 差得最少的 5 个,每条带**逐槽位对照**
(对上的和没对上的都列 —— 只列差异的话,人分不清「其余几项真的相同」
还是「其余几项压根没比」)Relaxation isn't only done at zero hits. When 300 units hit and the customer wants 2,800, the 8,932 units after relaxation are the answer;
if it only computed when hits === 0, this case would get nothing at all. The criterion is "does this tier satisfy how many needed",
not "is there a hit" — so without how many needed it never triggers, the tool only answers "how much is there".
Relaxation is a read operation: presenting candidates isn't asserting they'll work. The response states clearly which criterion was relaxed
and that the remaining slots are identical field by field; "can it plug in" is left to humans and the model (QSFP and QSFP28 are two spellings
of the same cage, SR4 multimode and LR4 singlemode simply don't connect). When relaxation surfaces multiple package types, a ❓ ask a human here is attached.
The division of labor is fixed: the model does translation and selection, the machine does judgment. Translation output is constrained by the vocabulary and verifiable on the spot; "are two slot sets the same model" is not handed to the model at all — if it were, the same pair of models would be judged the same today and different tomorrow. The criterion is "is the answer in an enumerable set": if yes (4 asset types, 80 models in a category, 32 brands, 24 slot values) → let the model pick, and a wrong pick is verifiable on the spot; if no (can A replace B) → the machine computes.
No guessing asset type. Originally all four parsers were tried and whichever filled the most slots won — 61 of 189 real models couldn't be guessed,
and 5 were recognized by multiple categories at once (128GB 2Rx4 PC5-5600B recognized by both optical module and memory), decided by a one-vote margin.
Now if the spelling isn't in the inventory and no asset type is given, it errors and asks the human to supply it.
But only one attribute truly can't determine the category; everything else can (all four categories' real spellings were fully parsed on 2026-08-15):
of 101 slot=value pairs, 96 were used by only one category; the only shared ones are 5 values of rate —
10G / 25G / 100G / 200G / 400G, present in both optical modules and NICs. Memory's cap (16/64/96/128G) and disk's
cap (480G and up) don't collide at all. So the instructions only carry this one hint (~122 tokens, sent once per session),
and no "capacity → asset type" index was built — where an index is strong (QSFP28→optical module, 3.84TB→disk) the model already gets it right,
and where it's weak (given only a 400G) it gets stuck the same way. Building the table adds determinism where the model is already correct,
does nothing where it struggles, and adds another thing that can go stale.
Two holes in the slot path (measured on 2026-08-23)
Querying a specific model can return the entire asset type's inventory, while the response says "matched by slot, only field-by-field equal counts as a hit".
Both holes share one root: compareOne() only iterates core slots, and "slots not given in the requirement → continue" (no constraint).
Continuing all the way through means "every row is field-by-field equal".
Hole one: the requirement has no core slots at all. Two paths can reach it:
How it happens | Measured |
Model parses to an empty slot set. 23 of the 189 real models in inventory are like this (12 disk / 9 NIC / 2 memory), all vendor part numbers: | Query |
Explicitly given slots all fall outside core. Optical module's |
|
The direction is the worst one — over-reporting: under-reporting gets follow-up questions, over-reporting gets approved outright.
Fix: matchBySlot first computes "how many slots will actually be compared" (= core ∩ requirement). When it's 0, no longer return matches; instead two branches —
The spelling's literal fingerprint is in the inventory → return only its own rows, marked
literal only, and state clearly "there may be other spellings of the same model in inventory, not searched this time".Not even the literal is there → return "query failed" + what to do (translate into core slots per the vocabulary and re-query, or call
View Available Modelsfor the list to pick from).
Literal comparison uses ledger.literalFingerprint (uppercase + separators removed, same convention as reservationWrite.normalizedModel and the normalize grouping),
not strict equality — strict equality was measured once, and customers writing part numbers in lowercase lost everything (-21 rows).
No prefix/contains matching: that path was deliberately removed from this repo, it misses same-model items with "an extra segment in the middle" (measured: 18,000 units missed).
Hole two: only part of the core slots resolve, yet it's reported as an exact match. This is far more common than hole one — 86 of 189 real models are partial coverage. The unresolved fields don't participate in comparison, which is semantically fine (unconstrained fields aren't filtered), but the sentence "field-by-field equal counts as a hit" hasn't changed a word, so "truly exact" and "only constrained a fifth of the fields" look identical in the response. Measured amplification:
Core slots resolved | Average models matched |
Optical module 5/5 | 1.3 |
Optical module 4/5 | 2.4 |
Optical module 3/5 | 4.0 |
Optical module 1/5 | 38 (38 of 80. That model is |
Disk 4/4 | 1.0 |
Disk 1/4 | 6.7 |
The fix is not refusal (returning 11 types when the customer says 3.84T is correct), it's stating the coverage:
matchBySlot returns parseCoverage {participated, notParticipated, fullCoverage}, and howFiltered states explicitly when coverage isn't full:
"This is not an exact match — of 4 core fields only cap was constrained; bus, form, gen didn't participate in comparison,
so the 11 rows below mix items with different bus/form/gen. Don't report this to people as 'this exact model'."
Incidentally, part of that 100% recall was fake. Those 23 part numbers used to match the whole category, so the ground truth was naturally inside → counted as recall.
After the fix, the withProjectSuffix row dropped to 88%, and attributing the 23 lost rows one by one confirmed they were all these part numbers, not a single real regression — that's why the baseline dropped
(see the baseline comment in tests/recall.test.mjs). The other rows returned to their original levels via the literal fingerprint:
allLowercase/hyphenToSpace back to 100%, removeAllSeparators back to 96% (the remaining 8 are real gaps that were already missed before the fix).
All three gates were removed from source and verified to go red: hole one's gate → checkInventory.test.mjs red; literal changed back to strict equality → recall.test.mjs red;
hole two's section → checkInventory.test.mjs red.
Three "lists for the model to look at", boundaries are fixed
看源表 这个数从哪张表、哪一列来的 —— 答来源
看有哪些型号 这一类有哪些标准写法和品牌 —— 答清单,给模型挑
看筛选 每条规则认识哪些取值、变了什么 —— 答原材料,给模型判View Filters: what's handed over is raw material, not conclusions
Which values each of the 45 rules (table × rule field) recognizes, how many rows each, plus the diff against the last baseline.
Full set ~1,500 tokens, onlyChanged: true ~170.
The previous version had the machine judging here: "hit rate dropping more than 20 percentage points = ⚠ significant decline". That threshold was made up, marked unverified, and the same change means completely different things in three contexts — a human deliberately changed a filter rule / a source table's column was changed / a batch of new-status items really came in; the machine can't tell which. Measured: three tables sit at 7% / 15% / 20% year-round (full asset ledger, most rows are "online" in-use devices), and any absolute threshold would falsely flag them as anomalies.
The current division:
Who | Does what |
Machine | Collects value distributions for all 45 rules; does a diff against last time (pure diff, no judgment); keeps the zero-false-positive absolute criterion of "read valid rows but not a single one matched" |
Model | Which context these changes belong to; whether excluding a certain value matters ( |
Human | Whether to roll the baseline — |
View Available Models: closed list, let the model pick
All canonical spellings of a category in inventory. Optical module 80 = 1,128 tokens, disk 62 = 512, memory 17 = 285.
When the customer's spelling isn't standard and the model isn't sure which inventory entry it maps to, call this — pick an exact one from the list and query with it.
withSlots: true appends the parse result to each entry (optical module grows to 4,025 tokens).
Also gives which brands exist in this category and how much stock each has (descending by count, only brands with stock) — the brand list in the instructions
only has "canonical name ← alias", so the model can't see the distribution when picking a brand, and might pick one that isn't in inventory at all,
then get 0 and can't tell whether it's "no such brand" or "I mistranslated".
Those two enums are extracted live from the solution package, not hardcoded in server.mjs (extractEnums(), lib/ledger.mjs) —
same rule as "slot parsing extracted live from Tampermonkey". They used to be copied 9 times each (the inputSchemas of five tools);
when the solution package adds an asset category or a warehouse, this side doesn't react at all: the model can't filter on it, that batch of goods can't be queried, and no error is raised.
When the solution package can't be extracted, give no enum, not an empty enum — an empty enum means "nothing may be filled in",
a silent total ban; the parameter then degrades to a free string, and the first real call fails with the actual reason.
tests/direct.test.mjs criterion ㊳ greps server.mjs directly; hardcoding one turns it red.
In stock ≠ available qty: in stock comes from the source tables in the solution package (real-time), reserved comes from the reservation records in the ledger,
available = in stock − reserved. External commitments are based on available qty. Reading the ledger measured at 4.4 seconds, so it's read on demand:
Call | Time (hot) | What it answers |
| 2.5 s | In stock, with the note "this is in stock, not available quantity" |
| 5.1 s | Three numbers: in stock / occupied / available |
| 2.5 s | Does not read occupancy |
| 4.6 s | "Enough or not" must deduct what's occupied |
| 2.5 s | Does not read occupancy (it's a list, not whether it can be taken) |
| 2.5 s | Does not read occupancy |
| 0 s | Only reads |
The three tools' defaults are deliberately different: 查库存 asks "how many are there", 找替代 asks "can it cover" —
the latter implies "can it actually be obtained", and if it says enough but it's actually occupied, someone will make a wasted trip to procurement. That's why 找替代 doesn't offer a toggle to turn it off.
Each candidate in 找替代 gives both "in stock" and "available quantity" at the same time; 够不够 and 小计 are both calculated by available quantity,
and sorting is also by available quantity (items with lots in stock but fully occupied are not allowed to rank first). The difference between the two numbers is itself what the person needs to know.
In the similar tier, items that differ by only one slot and are identical in every other field get a separate note 只差这一项 (QSFP28-100G-SR4
vs QSFP-100G-SR4 differ only in form). The person has decided: not the same product, but related categories should be recommended —
so they still only go into the similar tier, never into exact/rule; what's flagged is the computed fact of "which field differs, which other fields are identical",
not "therefore it can substitute" — that's optical module knowledge, not something slot comparison can compute.
The similar tier defaults to "layered", not "top N": grouped by how many fields differ, each layer reports a count, total units, and the top 2 representatives
(with full differences and the complete 只差这一项 sentence). Measured in one query: the full similar tier is 192 entries —
18 entries/6,640 units differ by 1 field, 16 entries/21,664 units differ by 2 fields … 57 entries/24,434 units differ by 5 fields.
When only the top 5 after sorting are given, the 187 truncated entries collapse into one sentence "there are 187 more specs", and the person can't see what magnitude they differ by;
after layering, "18 entries differ by 1 field" and "57 entries differ by 5 fields" are two completely different signals.
The same "how many fields differ" is further split by cost (2026-08-15): differing by a wave (1310 vs 1300, can be used)
and differing by a std (DR4 vs FR4, multimode swapped for single-mode, won't work at all) are both "differ by 1 field", and mixed in one layer the person has to read the whole layer
to know which ones are worth looking at. The layering key is "how many fields differ + the heaviest relaxation cost among those fields", and each layer also carries a
怎么读 note (can be used → look at this layer first; cannot be used → don't recommend unless the person has other grounds).
Take the heaviest, not the average: if any one of two differing fields cannot be relaxed, that candidate cannot be relaxed, and no matter how good the other field is, the conclusion doesn't change.
Sorting is first by how many fields differ, then within the same count by cost from light to heavy (criteria ㊳㊴㊵ + ablation 10).
The flat "similar candidates" list and the layered view must be in the same order (criteria ㊶㊷ + ablation 11). When adding layering, we nearly buried a pitfall:
the flat list was still sorted by the old rule (fields differ → lower than requirement → units descending), and when all three candidates "differ by 1 field" it fell all the way to
sorting by units descending, and the one with the most units happened to be the one least worth recommending — measured: differing by std (cannot be relaxed, 33 units) ranked first,
differing by wave (can be relaxed, 11 units) ranked last, while the layered view was exactly reversed. The same batch of goods has opposite order in the two views:
the person looking at the layered view sees the most important thing first, the person wanting the flat list sees the least important thing first.
Now "heaviest cost" is in the sort key, ahead of units: a part that can't be plugged in is useless no matter how many units there are.
The full sorted list is not deleted (truncation behavior, within-layer ordering, per-entry difference counts can only be verified by it; cutting it once
left 8 criteria with no basis on the spot). To get it, explicitly pass 每档几个.
When occupancy can't be read, don't fall back to passing off in-stock as available: the candidate simply omits the "available quantity" field (an "available quantity" equal to in-stock
is far more dangerous than omitting it — it looks like an already-deducted number), and both 小计.按什么算的 and 够不够
self-report that they were judged by in-stock, with a ⚠. In tests/substitute.test.mjs, ablation 4 must turn red if this layer is removed.
When the ledger can't be read, don't fail the whole query; instead report ⚠ 可用量算不出来 — "nobody occupies it" and "can't compute it" are two different things.
Every return value carries the same "口径" (basis) block (data source, read time and identity, total in-stock units, how brands were filled in,
defective-item exclusion, SN comparison, filter hit rate). Not made into a separate "check data freshness" tool — the model wouldn't
proactively call it, so the person would never see it. Fields starting with ⚠ are required by the tool description to be relayed verbatim.
The basis block only keeps fields that would change this answer. Per-section timing, read seconds, filter/dedup chain, structure cache,
normalization changes, criterion source, ledger raw row count — these are for the person debugging the code; the model has to read them every time,
and after a few rounds it's pure noise — measured at about half the basis block and about 20% of the entire return body. Not sent by default; only sent with INVENTORY_VERBOSE=1.
It's not deleted: when something goes wrong, those numbers are the only clue for locating it. tests/scope.test.mjs guards
"not a single field that should be sent has been slimmed away" — missing one timing number hurts nobody, but missing one "brand was filled in"
is hiding the fact that "we guessed this brand", and hiding it won't produce an error.
The first field of the return body is "how to answer"
Cheap models will write the result as a long run-on narrative. The format instruction goes in the first field of the return body, because it's read from top to bottom; an instruction placed after several thousand tokens of data barely takes effect; writing it only in the tool description doesn't work either — the description is read once at the start of the session and gets pushed far away after a few rounds, while the return body is something it re-reads every time it organizes an answer.
怎么答: 先出一张表:品牌 | 型号 | 库房 | 在库 | 占用中 | 可用量。
表下面用短句补这几条,一条一行:⚠ 开头的每一条原样带上、
同一型号在多个库房时按库房逐行列,不许加总成一个数、数据读取时间。
别写查询过程、别复述字段名、别加收尾总结段。Tier doesn't go into the table header. It's for judging "whether this batch needs cross-warehouse coordination", not a column for people to look at — what people want is "which brand, in which warehouse, how many". Stuffing it in adds a column of numbers nobody reads to every table.
Cost is about 130 tokens per call, in exchange for a whole paragraph of "I called the inventory tool, and the query returned the following results…".
It only governs shape — which fields must be relayed is still up to each ⚠ and the tool description.
Parameter echo: write back the conditions actually used this time
Multi-turn follow-ups are where models make the most mistakes, and mistakes don't error out: after the person asks "how many 400G DR4 in Minhang" and then says "what about Lingang", the model has to rely on memory of what it passed last round — miss one slot and the query returns a much larger set, carry one extra and it returns a much smaller set; both produce a number that looks normal, and the person can't tell it's not what they asked.
查库存 now echoes back the conditions actually used this time, placed before the data (after several thousand tokens of data the model can't read it):
这次查的: { 资产类型:'光模块', 槽位:'rate=400G,std=DR4,media=SM,wave=1310', 库房:'闵行' }
换条件时: 照抄「这次查的」改一项,别凭印象重写 —— 少一个槽位会多查出一大截、
多一个会少一大截,两种都不报错。What's echoed is the parsed slots, not the raw parameters. In the example above the model passed 型号:"400G DR4",
and the DR4 standard automatically implied single-mode and 1310 wavelength — it was actually asking about four constraints without knowing it.
After the echo it can see them, and if it wants to relax, it can precisely delete the wave=1310 segment instead of rewriting the whole thing.
It must be serialized into the string form the 槽位 parameter originally accepts (k=v,k=v), not an object —
an object looks more structured, but the model can't paste it back, and the round trip breaks.
The criterion is the round trip, not "does the echo contain this field" (tests/protocol.test.mjs ㉜㉝㉞):
re-query with the echo verbatim, and the total must be identical. If only shape is verified, a "echo an object" implementation would pass green while the functionality is broken.
No "query ID" scheme: that requires the tool side to keep state; the echo doesn't.
The closing line: the tool computes, the model copies (lib/凑单.mjs)
After a batch of models is queried, what the person wants is one line per model: "enough or not, where to pull from". This line is the basis for the person to place an order — if it says "Shanxi 302 + Lingang 283", the person pulls those quantities from the two warehouses. If the model sums it up from the details itself, nothing will error out if it adds wrong, and the shortage is only discovered when the goods arrive. So the machine computes:
一处就够 QSFPDD-400G-DR4:光迅·山西 满足
要凑好几处 QSFPDD-400G-DR4:光迅·山西 302 + 海光芯创·临港9号楼 283 = 585 满足
凑不够 QSFPDD-400G-DR4:全部 8 处合计 1073,缺 1727
没给「要几根」 QSFPDD-400G-DR4:光迅·山西 (附「这只是货最多的那一处,不代表够」)Three rules:
Each place in the combination carries its own quantity. Writing
光迅+海光芯创·山西+临港9号楼 满足is syntactically fine and reads smoothly, but the person doesn't know how much to pull from Shanxi and how much from Lingang — and this mistake won't turn anything red.When it can't be fully covered, don't spread out each place, only report the total and the shortfall. The numbers for those N places are already in "按库房"; putting them in this line only makes the person think "these add up to the answer".
Without "how many needed", "满足" must not appear (return
够: null) — the tool hasn't computed whether it's enough, and writing "满足" at that point is the model drawing a conclusion on the person's behalf.
"Using the fewest places" relies on greedy, and greedy is the optimal solution for this problem: taking the largest k makes the sum of k items maximal, so the first k that suffices is the minimal k. The premise is "each place can be fully taken" — if a cap like "at most 200 units from one warehouse" is ever added, this no longer holds and the algorithm has to change.
Switching machines / something breaks: run ./自检.mjs first
./自检.mjs 全查一遍(会真读几张源表,约 8 秒)
./自检.mjs --快 跳过真读那步,不打网络Three things about this system are not in this repository, and you can't tell from the code alone:
What's missing | Symptom | What to do |
| Won't start | It goes with WorkBuddy, not installed separately — install WorkBuddy and open it once. If it moved, set |
| Won't start | Search in order: |
Read permission on the Feishu side | "Can't read the table" | The easiest to get stuck on and the hardest to see — it looks exactly like a wrong path or a broken network. Ask the table's maintainer for permission |
No login state to configure: lark-cli uses the same one as WorkBuddy (identitySource: auto_detect),
the person who installed it just logs into their own Feishu account, no secrets needed.
发通知 doesn't actually send now (it only assembles text and returns it, you paste and send it yourself), so no send permission is needed. If a bot auto-send is ever wanted, the measured hurdles: sending as a user is blocked by tenant policy (230027); a bot DM to someone requires that person to be in the available scope of app cli_aae5ee90f8f85cc5 (230013), sending to a group requires the bot to already be in the group (230002); bot sending requires --as bot + im:message scope (lark-cli auth login --recommend). The only zero-hurdle path is "bot sends to a group it's in + <at user_id> to @ someone".
Every red line is followed by a "what to do", and one broken item doesn't block the rest —
when switching machines the person wants to see everything at once, not fix one and run again. Both rules have criteria guarding them (tests/自检.test.mjs).
Three static checks (all in the 15-second tier)
Language-level errors go to that language's own tools, not hand-written regexes. All three are installed globally (shellcheck / eslint via brew and npm -g),
and the repo itself has zero JS dependencies — ESLint uses the global binary plus one eslint.config.mjs in the repo, no node_modules.
Tool | What it catches | What it flagged the first time it ran today |
| shell | My just-written |
| JS | Three dead-code spots; and during replay verification it pointed precisely at |
| Renaming by syntax tree (not a check, used when changing code) | — |
Only no-undef and no-unused-vars are on; not a single style rule. This repo's trade-offs (Chinese identifiers, long comments,
inline ternaries) are deliberate; letting a linter govern style only produces a pile of noise needing exemptions, and noise makes people ignore the real errors too.
Three things to guard against when integrating this kind of tool; miss any one and it will be "green when broken":
Not installed must not silently skip — otherwise it's "passing" forever.
Check how many files it scanned — reporting 0 files and reporting 0 problems look identical, and the former means it didn't check. shellcheck additionally needs
SC1088watched: when it hits syntax it doesn't recognize it stops parsing but still exits non-zero, 260 lines scanned as only 28 while looking like it's working (this is why the function names inrun-tests.sharesec/run_one/teeth/chain).Trust only the exit code, not the output text — the first version matched eslint output with
grep ' error ', but it prints[Error/no-undef](capitalized, no space), so nothing matched, and thus eslint reported an error while this check printed ✓. A check specifically for "green when broken" was itself green when broken.
Batch renames always use ast-grep, never sed / string replacement. Measured comparison (in the same code, "窄读" has three identities):
盲替换 注释、字符串、词义不同的地方全被换 —— 4 处里 3 处是错的
ast-grep 只换标识符那 2 处,注释和字符串一个字没动sed's \b doesn't work on Chinese (s/\b中文\b/x/ changes not a single character, while you think it did).
There's also a form shellcheck doesn't report: when $var is immediately followed by Chinese punctuation, bash treats those bytes
as part of the variable name ($es_code) prints garbage). After a variable, always write ${var} when followed by non-ASCII.
The third form was manufactured by that very rename. e9d5aa8 (08-17 23:28, the "function names to ASCII" commit)
changed 牙 to teeth but missed one call site in the parallel chain (run-tests.sh:172). bash only reports 牙: command not found at runtime,
the exit code is unaffected and the summary still prints ✓ — so the ablations for the four network-hitting chains
(4 segmented reads, 2 incremental, 2 read-only-needed-tables, 2 write paths) never ran even once for a full 15 hours,
while ./run-tests.sh 全 was all green every time.
bash -npasses — command names are resolved at runtimeshellcheck -S warningexits 0 — it doesn't check whether functions are definedWhat caught it wasn't any layer of checking, but a person scanning the output and seeing those four
command not foundlines, plus the mismatched number of "full run 118 s, noticeably shorter than the 195 s in the record"
After the fix and rerun: 118 → 218 s, and those 10 ablations that had never run all turned red (they were fine themselves, just never executed). To this day there's no mechanism that would turn red for this, only a second count someone has to compare — the fix would be counting at the end of the full run whether the number of "has 牙" assertion lines is enough for the chain count; not done yet.
The dispatch path: let the cheapest tier catch it too
tests/分发.test.mjs, 0 s, 8 criteria, no network (goes through 看源表, which only reads the solution package).
Why it exists separately: when adding the Q&A log on 2026-08-18, I wrote 工具: name at the dispatch point —
and name doesn't exist in that scope at all. The consequence wasn't an error; it was 记这次 throwing a ReferenceError,
not a single log line written, while the query returned correct results as usual, and neither the person nor the model could see anything wrong.
What caught it was tests/protocol.test.mjs (hits the network, takes minutes). And before that,
no test in the 17-second tier had ever executed the tools/call dispatch path:
资源/scope don't spawn subprocesses, 通知 does but only calls tools/list.
So errors in the dispatch layer could only be caught by the slowest tier.
Replay-verified: putting 工具: name back → this test turns red on the spot (tools/call 超时 20 秒), and reverting turns it green again.
It guards the dispatch layer itself, not any tool's business criteria: whether tools/call works, whether the return body was mangled,
whether the hooks at the dispatch point actually produced side effects (if only return values are checked, a hook silently throwing is invisible),
renamed old parameters get rejected on the spot, unknown tool names error out and list what exists, ping returns an empty result.
One word on ping: it's a liveness probe, it must not fall into the fallback -32601 — the client uses it to judge whether the connection is alive,
and "method not supported" and "the process is gone" look the same on the client side, while the server is actually fine.
The general lesson went into the global rules: every cheap verification path must genuinely cover that code — a path only executed by the most expensive tier means its errors take the longest to become visible.
Resource list only shows the most recent few (lib/资源.mjs)
resources/list used to list 30 items, 28 of which were historical export snapshots. The client pulls this list to know
"what's readable here", and the answer shouldn't be twenty lines of timestamps. Now each category lists at most 3
(INVENTORY_RESOURCE_LIST_MAX is adjustable), measured 30 → 9: three static resources + the most recent 3 exports + the most recent 3 weekly reports.
It's not fear of the list growing out of control — 清旧导出 already keeps only 20 per directory, the disk side is managed. It's signal-to-noise.
The premise for truncation is "what's cut off is still reachable", and both paths are required: the full list is at inventory://导出
(all filenames, sizes, paths), individual files go through the inventory://导出/{文件名} template with name completion.
resources/read was never limited by the list; it reads any uri as-is. If those two are ever removed, truncation goes from
"reasonable noise reduction" to "not in the list = this file doesn't exist" — tests/资源.test.mjs ⑯ and ⑭ are a pair, guarding exactly this.
Q&A log: swap the eval set from "what I made up" to "what people asked"
lib/问答日志.mjs + ./问了什么.mjs. Each tool call appends one JSONL line to
~/.cache/inventory-mcp/问答日志/2026-08.jsonl, split by month, keeping only the last three months.
Why it's needed: before 2026-08-18 there wasn't a single query log — we didn't even know how many times "hit 0" happened.
And tests/召回.test.mjs measures 189 real models × 9 kinds of mechanical perturbation, and those 9 kinds are what I made up, not what people asked:
96% shows the parser isn't afraid of case or hyphens, but says nothing about "can what people ask be found". This log is the raw material for replacing the eval set.
./问了什么.mjs 这个月:按工具/资产类型/档位分布 + 耗时和返回体分位数
./问了什么.mjs 2026-07 指定月份
./问了什么.mjs --没答好 只列命中 0 和出错的 —— 这些才是拿去改召回的样本Three hard rules, each with criteria in tests/问答日志.test.mjs:
Parameters are logged verbatim, defaults are not filled in — "he didn't fill it" and "he filled the default" are two different things; filling it in makes it impossible to tell whether the model missed a field.
Failures are logged too — hit 0 and "couldn't run at all" are two different problem classes; mixing them makes it impossible to tell "this item really doesn't exist" from "this chain is broken".
Must not slow down queries — the append write isn't awaited, exceptions are swallowed entirely; a broken log shouldn't stop people from finding stock.
The logging is consolidated at the tool dispatch point in server.mjs, one place covering thirteen tools —
if it were scattered into each tool, sooner or later a new tool would forget to log, and "one tool not logged" has nowhere that would report it.
Incidentally plugged a hole in tests/不膨胀.test.mjs: it originally only scanned five hardcoded filenames,
so a new module with mkdirSync was completely invisible to it — the directory grew quietly, while the criterion guarding
"things that grow must have cleanup" stayed green. After changing it to scan all source files, it blocked me once on the spot when adding this log
(日志目录 不在「该有的清理」名单里), and only registering 清旧日志 let it pass.
A check against missing configuration had its own misconfigured list — the most ironic kind of failure in this whole system.
What takes up space in the return body (measured 2026-08-18)
One 查库存 returns 8840 characters; broken down, the bulk isn't where I thought it was:
5487 字符 62% 明细(10 行) ← 其中 物料键 一个字段就占四成
1394 字符 16% 口径 ← 源表链接 645(答案覆盖 6 个库房)
652 字符 7% 怎么答
319 字符 4% 按库房
其余 13 个字段加起来 不到 11%Three changes brought it to 7480 characters (saving 15%):
Details are sorted by tier. Before the change, the main detail had never been sorted once (it just sliced the first N rows), so the first thing seen could be
a large stock in tier two — and tier two is "needs coordination (occupied by projects)", while tier one is "freely available".
The first row is the one the person treats as the answer, so it must be the batch easiest to actually obtain.
Uses the same ordering as 按库房 (byTier); the two places must not each sort their own way; within the same tier and quantity, order is fixed by model, so two runs produce diffable output.
The in-conversation detail doesn't carry 物料键. It's just 资产类型|品牌|型号|库房 concatenated, and those four columns are already there —
in the conversation that's repeating the longest field of every row. The file-dump version still carries it: that's for people filling in occupancy records,
and assembling the four fields by hand will get it wrong (separator, spaces, case all have to match exactly).
Source-table link labels deduplicated (闵行/闵行: → 闵行:). The link itself is untouched —
it already only gives "the warehouses this answer covers", not "all tables read this time".
Relaxation cost table: pick by cost, not by how much it hauls in (lib/substitute.mjs)
When nothing is found, the tool "relaxes one field" and queries again. Originally the field picked was the one hauling in the most,
and the one hauling in the most happened to be the most costly one — the customer wants 100G LR4 单模, relaxing std
immediately reports 8,932 units of SR4 多模, specs "identical" item by item, the numbers look great, and it doesn't light up when plugged in.
Picking by yield is equivalent to recommending the most dangerous field first.
The 方向 table says "does a candidate higher than the requirement count as usable"; this table says "when nothing is found, leaving this field entirely unconstrained,
how risky is it" — two different things, two tables:
Can relax | Relax with caution | Cannot relax | |
Optical modules |
|
|
|
Memory |
|
|
|
Drives |
|
|
|
NICs | — |
|
|
wave being judged "can relax" came from running the data, not from reasoning by principle. Grouping all optical modules by std and counting wavelengths:
of 23 std values, only 2 correspond to more than one wavelength, and both are two spellings of the same wavelength —
FR4 is 1300 (597 units)/1310 (64 units), SR4 is 850 (15,872 units)/840 (8 units).
No std crosses into a genuinely different optical wavelength. So relaxing wave recovers spelling differences, not a different product.
(The single-mode↔multimode line is governed by media, and that cell is "relax with caution".)
Behavior: only "can relax" fields are auto-relaxed; "relax with caution" ones are laid out and explicitly require the person to confirm;
"cannot relax" ones each carry a "don't treat it as the answer" note and are sorted last.
Slots with no defined cost are treated as "cannot relax" (the fallback of 放宽代价是()) —
a missing cell shouldn't become "relaxable by default"; tests/substitute.test.mjs ㉝ checks each one against the parser's core.
Refusal: the only place this system can say "there is none"
Before this table, the system couldn't say "this item really doesn't exist": zero hits were always interpreted as "the match didn't line up",
and the prompt even said "you are not allowed to say this item doesn't exist". So when someone wants 100G LR4 单模 and the inventory only has SR4 多模,
the tool reports "there are 8,932 units". Being able to say "there is none" and daring to say "there is" are two sides of the same thing —
a system that always says "there is" isn't believed when it says "there is" either.
There's only one criterion: which tier's slot had to be relaxed to haul in the goods. All three tiers fail → still "the match didn't line up";
only "cannot relax" fields can haul it in → the "not found" message explicitly writes 这一次可以直说, and the 怎么答 rule
"not allowed to say there is none" simultaneously gives the single exception. Measured:
槽位 rate=800G,std=SR8,media=SM,wave=1310
→ 「能捞到货的那几项全是不能放的(std)… 这一次可以直说「这个规格库里没有」」
(放开 std 有 18,377 根,但那是另一种货)记下决定: what the person has decided doesn't get asked again (lib/决定.mjs)
This is the only part of the system that gets more accurate with use. Before it: the customer decided "QSFP28 can be covered by QSFP", and the next round asked from scratch again — the same question once a week, and the answer could differ each time.
Why not write it into the solution package's modelAliases. That table governs "these two spellings are the same product",
and writing it there would merge the two sides' inventory into one material key. But "A can cover B" doesn't mean "A is B":
QSFP-100G-SR4-MM850 can cover QSFP28-100G-SR4, but they're two products, two keys, two inventory balances.
The consequence of mixing them in is the inventory numbers distort on the spot, with no error. So a separate file (决定.json, travels with the code,
INVENTORY_DECISIONS is configurable), affects only recommendations, not "how many there are".
Four guardrails:
Basis and "who decided" must not be empty — this decision will have to be accounted for by someone later. Same rule as "brand fill-in".
The date is given by the caller, the module doesn't take it itself — if it did, you couldn't run it twice to verify idempotency.
Only one entry per model pair, dedup is direction-insensitive (what the person decided is "can these two cover each other"), compared after normalization (
QSFP-100Gandqsfp 100gare the same pair). When the decision changes, the previous version stays in改过: "last week said it can cover, this week says it can't" is itself something to show people.A candidate decided "cannot cover" isn't deleted, only marked — deleting it would hide "we already judged this", and someone will ask again next time.
When it can't be read (file corrupted), 读决定 throws directly, not treated as an empty table — treating it as empty silently zeroes out the person's decisions,
and the next query just looks like "being asked again". But the 挂决定 path swallows exceptions: a broken decision table shouldn't fail the whole query.
查SN: big results go to a file, not dumped into the conversation
Each source-table record is one unit of goods with an SN; when aggregating into a material key, that column gets compressed away — 查SN (lib/sn.mjs)
restores it. Three things are the entire content of this tool:
① The snapshot's format is pre-normalization, so you have to map it back. The SN detail comes from the 收SN() snapshot in SN\t资产类型|品牌|型号|库房 format — the last three segments are verbatim from the source table (闵行 and 闵行库房 are two distinct values, as are SAMSUNG and Samsung). So first build a reverse-lookup index using norm.rows's 原始 field (which records exactly 品牌0|型号0) plus the PLACE table. Without this step, a query for "128G memory in 闵行" will miss the 5,838 sticks whose source row says 闵行库房 — and the missing portion doesn't error out, the number is just smaller. If one raw spelling maps to two material keys, that throws — it means normalization is no longer a function, and whichever one you pick is a guess.
② SN detail follows load()'s return value, not the snapshot on disk. The snapshot is written fire-and-forget; right after a cold read returns, the disk still holds the previous one; a hot hit never rewrites it. Carry it out of the result (~10MB), and then SN and norm are guaranteed to come from the same read, so 在库 = 有SN + 缺SN balances correctly.
③ "Sticks in stock" and "sticks with SN" are always reported separately. The source table's SN column has blanks, so the two numbers aren't equal; if you merge them, people will use the SN count as the stock count. When they don't match, the return includes ⚠ 有货没有 SN. There's also 认不出的 (whole-warehouse scope): SNs that match no material key at all. Normally 0; non-zero means normalization and snapshot collection picked different rows — this number must not be swallowed, or that batch of goods vanishes from every SN query.
When the count exceeds 最多列几条 (default 50, adjustable via INVENTORY_SN_INLINE), items are no longer listed in the conversation; instead a BOM-bearing CSV is written, and the return only gives the path plus per-material-key group counts. The destination splits by "who asked for this file": a person explicitly asked (一定要文件: true) → Desktop; the tool wrote it because it was too large to fit in the conversation → ~/.cache/inventory-mcp/导出/. The latter is the tool's internal move to save tokens — no person asked for it, so it shouldn't occupy their desktop. The consequence of conflating the two was measured: an afternoon of testing produced 20 CSVs plastered on the desktop. Each directory keeps the most recent 20 files, deleting only names it generated itself by its own format. 50 is set by token math: one SN is ~5 tokens, 50 is ~250, putting the whole return body in the same order of magnitude as a normal 查库存 (~1,400 tokens); beyond that it starts squeezing the headroom of subsequent turns, and when someone wants a thousand SNs, what they actually want is that file, not a scrolling wall in the conversation. Files are only written when the cap is exceeded or 一定要文件 is explicit — not normally, because a written file needs someone to clean it up, and nobody cleans a directory that grows one file per query.
A zero hit gets a separate note — "this doesn't mean the item isn't in stock": the condition compares normalized spellings, and a bare 0 will be reported by the model as "this item doesn't exist".
看变动: real in/out is counted by SN; key-level changes are noise
For the question "what changed", the SN对比 in the metrics block can't answer it — it compares against "the last time anyone ran this MCP", and any query overwrites the baseline (including the agent's own casual query). So it almost always shows "一致". The real baseline is the 周基线/YYYY-MM-DD.tsv written by ./周更.mjs every Thursday; 看变动 reads that, read-only, never touches the baseline.
Key-level changes lie — that's the entire design pressure of this tool. Measured 08-06 → 08-13:
Metric | Number |
Key level: 30 keys disappeared / 48 keys added, Building 45 alone lost 3,573 sticks at once | looks like something major happened |
By SN: 32 sticks truly out / 57 sticks truly in | what actually moved |
Just spelling changes | 11,336 sticks |
The entire difference is the same batch of goods having blank brands filled in as CLT / 光迅 / H3C — not a single SN moved. Filtering to Building 45 alone is even cleaner: 75 keys changed, true in/out 0 / 0. So the return order is fixed: true in/out first, ⚠ 别把改写法当出入库 right after, and the key-level "truly disappeared / truly added" are what remains after stripping out spelling changes (解释改名(), lib/weekly.mjs). The criterion is SN: if an SN exists on both sides it didn't move, no matter how the key is written.
A key loses 5 sticks, 3 of which are just spelling changes → report "truly down 2", not 5 and not 0. tests/weekly.test.mjs ablation 3 must turn red if this layer is removed.
Requesting a baseline that doesn't exist errors out and lists what does exist, rather than returning "no changes" — conflating "this baseline doesn't exist" with "nothing moved in this period" would make people think the books are balanced.
看源表: which table and which column these numbers came from
This tool was forced into existence by a real wrong answer: someone asked "all SNs for 128G memory", and I went through the ledger's three tables (存量明细/线下台账/占用记录), saw no SN column, and answered "SN data doesn't exist" — but the MCP doesn't read the ledger at all; it reads the source tables in the solution package, where every record is one SN. Seeing only the result without the source means you use the wrong place as evidence.
Table count follows the solution package — don't hardcode it in docs or assertions: after the CPU solution was removed on 2026-08-14 it went from 34 to 31, and the two assertions hardcoding 34 in tests/protocol.test.mjs turned red on the spot — now it's computed live from the solution package.
Reads only the solution package (plans.json), not a single network call: what's wanted is "how the config says this table is read", not "how many rows this table has right now" — the latter is 查库存's job.
The map entries where "source column name ≠ our field name" must be listed explicitly. Measured: 4 tables' SN isn't called SN:
SN: { 有: "31/31 张",
列名不一样的: [ "网卡·山西/山西广灵:叫「外部SN」",
"硬盘·山西/山西台账:叫「外部SN(必填)」",
"内存·临港9号楼/B-2项目-9号楼资产表:叫「CMDBSN」", … ] }Reporting only "has SN" would send people looking for a column name that doesn't exist in the source table. Likewise "货位" exists in only some tables, and among these 11 the names include "货架-区块", "储位", "箱号".
Default gives only per-field summaries; per-table detail requires an explicit request (measured: summary 2,336 tokens, per-table 8,580). Both lists are truncated — without truncation the summary itself is 4,227 tokens, more absurd than the detail it's meant to save; when truncated, the remaining table count must be reported (tests/源表.test.mjs assertion ⑬).
Where the data comes from: two paths, switched by INVENTORY_SOURCE
direct(默认) ledger(INVENTORY_SOURCE=ledger)
31 张源表(28 张 + 临港移动7号楼 3 张) 飞书 线下台账 (340 行)
每张 1~2 次并发调用:列宽有缓存就直接只读一类 人每周从油猴导出件粘贴
冷启动约 11 秒 / 热态约 2.4 秒 2.5 秒
约 16 万根 / 360+ 个物料键 12.9 万根 / 325 个物料键
└──────────────┬──────────────┘
库房名统一 → 品牌归一 → 型号归一 → 按物料键聚合
物料键 = 资产类型 | 品牌 | 通用型号 | 位置(粗到库房)Both paths share the same normalization (normalize() in lib/ledger.mjs); the only difference is where the data comes from. The direct-read path has three extra steps: run build_stock's filter rules per the plan → dedupe by SN (lib/dedup.py) → aggregate one record per SN into ledger form.
The default is direct because it's both fast and complete: hot path ~2 seconds, comparable to the ledger read's 2.5 seconds, with 30,000+ more sticks, and the difference is explainable — the offline ledger for 临港移动7号楼 isn't included, and the rest is one week of lag plus manual-process misses. ledger stays as a fallback, not deleted.
No specific stick counts are hardcoded here: the source tables can be modified several times a day (2026-08-13 alone: 09:32 / 09:48 / 13:16), so a hardcoded number is wrong by the next day, and a stale precise number is more misleading than none. For the current number, run it once — it's in the metrics block.
The hot-path 2 seconds is concurrent revision checks on 9 documents; if none changed, the in-process cache is used, data always stays local, and only re-fetches on change. revision is used instead of latest_modify_time — the latter measured 2~5 seconds of lag, so a query right after an edit would say "nothing changed".
The solution package counts as part of "version" too (方案包指纹()): change a filter rule, add or remove a table, change a column mapping, and the revisions of those 9 Feishu documents won't change at all, yet the cache should invalidate. Measured: after removing one solution (9 fewer source tables), a subsequent query still answered with all source tables' numbers in hot mode (34 tables / 162,514 sticks then; the solution package now has 31). And the first thing a person does after seeing ⚠ 筛选列冒出新取值 is exactly to edit the solution package — if it's not counted, the change won't take effect until some unrelated table happens to be touched. The fingerprint is computed by content, not mtime: copy/sync/restore all change mtime, so judging by mtime would waste 6~7 seconds re-reading; when unreadable, use a fixed value rather than a random one, or every query would judge "changed".
Each re-read also does a per-record comparison: compress the 160,000 in-stock records into SN → 资产类型|品牌|型号|库房 stored as ~/.cache/inventory-mcp/sn-snapshot.tsv (~11MB, overwritten each time), diff against the previous one by SN, and put the result in the metrics block: how many fewer (out), how many more (in), and how many "SN unchanged but model spelling changed". The last category is reported separately because a by-SN set difference can't catch it (present on both sides), yet after aggregation it looks exactly like one-in-one-out — this is the most common cause of "two weeks of inventory models don't match". SN detail lands in sn-diff.json; the metrics block only carries numbers and the first few material keys (token savings). The old-snapshot read and network requests fire in parallel, and the new snapshot write doesn't wait, so it's off the critical path; the hot path never touches disk.
Cold start also has a disk-level schema cache (~/.cache/inventory-mcp/schema.json): stores wiki→document tokens and which column each table's columns occupy — these two barely change, and on a hit each table skips the "read the header first" round trip. The cache also records "this table needs segmentation, what segment length it converged to, how many rows last time". Cache expiry can't cause misreads — the body carries its own header row, which is re-validated every time for missing columns; on mismatch the cache is dropped and the slow path is taken (验表头(), guarded by tests/direct.test.mjs assertions ⑮~⑱ + ablation 4). The cached segment length may only be smaller than the current policy, never larger (夹段长()): without clamping, after 每段格子 is reduced, cached tables keep using the old large segment length, the new constant never takes effect for them, and it reads correctly, passes conservation, all checks green — just twice as slow.
Incremental re-read: only re-read the document whose revision changed
Previously any document change triggered a full re-read of all 31 tables. Now the decision is per-document by document-level revision; unchanged ones use the rows stored on disk (~/.cache/inventory-mcp/rows.json, measured 36 MB). The criterion is the same one as the hot path (only content changes count), just granularity refined from "use only if nothing changed" to "use this document if it didn't change".
全部都变了(=全量) 14.5 秒 复用 0 张
变了最大那本(6 张) 12.4 秒 复用 25 张
变了最小那本(1 张) 7.5 秒 复用 30 张
一本都没变(热态) 2.8 秒 复用 30 张The benefit depends entirely on which document changed — the largest one, containing the 80,000-row 闵行 optical module table, saves only 2 seconds. Of that 7.5 seconds, only one table is actually read; the rest is one round of revision checks (2 seconds) plus deserializing tens of MB from disk (**23 seconds, originally estimated at 0.3~0.5 seconds — off by nearly an order of magnitude**) plus 1.4 seconds of filtering and dedup.
Land on disk, not in memory: 330,000 rows in memory measured 168 MB of heap, and this MCP is a long-lived singleton process launched by WorkBuddy — keeping it in memory means holding it forever. Landing on disk trades for flat memory — and covers one more scenario the in-memory version can't: when WorkBuddy exits and reopens, the process is new, so it would always do a full cold read; now it can ask one round of revisions against the disk-stored ones and go incremental.
Three cases invalidate everything and fall back to full: solution-package fingerprint changed (those 9 revisions won't change at all, but how every table is read changed completely — there's no "which document changed" to speak of), force, or nothing on disk / fingerprint mismatch. Anything without a recorded token or revision is re-read — "don't know whether it changed" and "it didn't change" are two different things.
If the changed document fails to re-read, the old rows aren't available (复用 returns null for changed documents), so it goes through the explicit 缺表 degradation as usual: silently using old rows would hand the person "looks complete but is stale" numbers, far worse than a missing table. The row cache only stores tables that read successfully — storing an empty one would freeze "couldn't read this time" into "this table has no stock".
There's only one assertion, but it's the entire safety of this feature: the incremental result must match the full read exactly, material key by material key (tests/增量.test.mjs). "Conservation passed" can't be the criterion — if a document that actually changed is reused, the total is short by a chunk while every step's conservation stays green. Scenarios are built with INVENTORY_FAKE_CHANGED=<token fragment>, read from the environment on every call (the usage is "run a full pass first in the same process, then pretend one document changed and run a second pass"; if read once, the second pass can't change it, forcing tests to spawn subprocesses and hit the network fully every time).
Read only the tables for the asset type asked + in-flight dedup (2026-08-17)
Ask "are the optical modules enough" and it used to read all 31 source tables. Optical modules occupy only 7; the other 24 (NIC 9 + disk 9 + memory 6) don't use a single row. Worse, when the model asks "are these two models each enough" it fires two 查库存 calls at once, and the in-process cache is only written when a run finishes, so each reads a full pass.
Measured (before):
两个 load() 并发 55.0 秒,各自读回 332,763 行 ← 各读各的,双倍 API 调用
等第一个跑完再来第三个 2.0 秒 ← 这才走缓存After:
只问光模块(冷) 42.6 秒 读 7 张 / 237,109 行 / 119,090 根
再问网卡 8.7 秒 读 9 张,行缓存累计 16 张
两个硬盘并发 8.6 秒 只读一遍,两边拿到同一个结果对象
接着全量 9.8 秒 31 张里 25 张直接复用 —— 前面几次只读一类顺手把缓存捂热了
全量之后再问光模块 2.0 秒 走「全部」那份缓存,不重读Which tools read only one class — the criterion is "would this tool's answer use rows from another class": 查库存 / 看分布 / 找替代 / 看有哪些型号 read only one class; 查SN (an SN could belong to any class), 看变动 (per-record comparison is whole-warehouse scope), 看筛选 (needs whole-warehouse value distribution) must read everything.
Three rules, each blocking one kind of silent error:
Reading one class must not roll any baseline. Overwriting the whole-warehouse SN snapshot with one from only 7 tables freezes "didn't read NICs this time" into "all NIC stock is gone" — the next full comparison counts 200,000+ sticks as "added", while every step's conservation stays green. So reading one class only answers the question, takes on no monitoring duty: no SN snapshot write, no hit-baseline roll, no diff file, and no comparison against the old baseline (half data vs full baseline — 归零 would alarm once for every unread table).
The scope must self-report. Without the ⚠ 这次只读了部分源表 note, 在库总根数 silently changes from "whole warehouse" to "this class", and the number looks identical, so the model answers "how many total" an order of magnitude too small.
The row cache is additive, not overwriting. Reading one class brings back only 7 tables' rows; a full overwrite would wipe the other 24, and the next NIC query would need a full cold read — a change meant to save time instead slows other queries.
The "all" cache can answer any narrow question; the reverse doesn't hold (superset; the answer layer still filters by type). Without this asymmetry, asking about optical modules after a full query would re-read everything — the optimization would slow down the most common scenario.
Two escape hatches, which double as ablation injection points (if something can't be turned off, you can't prove it's doing anything): INVENTORY_NO_NARROW=1 falls back to full read, INVENTORY_NO_INFLIGHT=1 disables in-flight dedup.
Cross-solution dedup (the same SN counting as both NIC and disk) is cross-asset-type, and reading one class can't see cross-class collisions. Current data measures 0 records, so it doesn't affect any number today; the day it's non-zero, the full path will still report ⚠ 筛选规则有重叠.
What you actually wait, in seconds (measured 2026-08-17/18)
First, a distinction I myself got wrong: all the "cold read 41 seconds" numbers were measured in an empty temp directory (to avoid touching the real cache) — that's the first-run-on-a-new-machine scenario. Your scenario is WorkBuddy restart — the process is new, but the on-disk row cache is still there:
新进程 + 真缓存,问光模块 3.8 秒 ← 7 张全复用,读表 0 秒
同进程再问一次 1.9 秒
空目录冷读 7 张(新机器才遇到) 41.1 秒Breaking down that 3.8 seconds, 88% is concentrated in one place:
2.00 秒 问 9 本文档的 revision(一趟网络,已经全部并发了)
0.14 秒 起 python 去重 33 万条
0.08 秒 JSON.parse 那 37.6 MB 行缓存
0.03 秒 从盘上读那 37.6 MB
0.01 秒 importThat 2 seconds can't be compressed further — measured: lark-cli spawning a subprocess plus one network round trip is itself a 12 second floor — 9 concurrent metainfo calls and 1 metas/batch_query are equally fast (12 seconds each, same as a single call). So "merge into a batch call" is a dead end, and batch_query only has latest_modify_time (measured 2~5 seconds lag), no revision.
So move it out of the user's wait path: trust period
1.5 seconds after MCP startup, a background warm-up runs; after that, a background check every hour; queries use the last checked copy directly, not a single network call.
第一次(真核) 3.7 秒
信任期内 0.0 秒
后台定期核(绕过信任期) 2.1 秒
force(导台账 / 周更) 42.0 秒 ← 不受影响,永远真核真读The cost is data up to one hour stale (photo taken 2026-08-17). So:
Staleness must be visible: the metrics block carries
数据核对于: 2026-08-18 00:00:06(3 分钟前核对的,之后源表有没有被改过这次没查). Silently using one-hour-old numbers is the worst error this system could make — every step's conservation stays green, totals stay right, and only actually checking the source tables would reveal it.The background pass doesn't consume its own trust period (
_绕过信任), or it would always hit the cache, never actually verify, and the trust period becomes a lie that never expires.INVENTORY_TRUST_MS=0falls back to "verify on every query".
To go faster from here, the only remaining option is bypassing the lark-cli subprocess and sending HTTP directly (saving that 1~2 second floor), at the cost of taking over Feishu's auth and token refresh yourself — all currently managed by lark-cli.
Why reading tables is at its current speed (measured 2026-08-15)
Table reading is already at the concurrency ceiling; the only way to go faster is to read less. Three alternating measurements:
① 单张表切几段 闵行光模块 84,952 行 × 9 列,每档三轮取中位
5 段 7.7 秒 · 17 段 3.7 秒 · 34 段 3.2 秒 · 68 段 4.2 秒(掉头)
② 元数据怎么发 串行(拿行数→再发段) 20.1 秒 · 段并发 7.8 秒 · 元数据与段同批 5.0 秒
③ 全局并发几路 31 张 33 万行:4 路 28.5 秒 · 8 路 13.0 秒 · 32 路 14.2 秒Concurrency overlaps "waiting", not "transferring": request time = wait (round-trip latency) + transfer (pipe occupancy). Concurrency stacks multiple "waits", but bytes don't shrink just because they're sent simultaneously. So the curve drops, flattens, then rises slightly — in ③, 8 lanes already saturate, and 32 lanes is slightly slower (dozens of lark-cli processes fighting for CPU); same for the 68 segments in ①. INVENTORY_CONCURRENCY defaults to 8, 每段格子 to 45,000 (17 segments) — both set this way.
Segment length doesn't take the fastest 34 segments; it trades 0.5 seconds for rate-limit headroom: call count scales with segment count (5 segments ≈ 36 calls / 17 ≈ 48 / 34 ≈ 65), and the narrowest tier is 100 calls/minute — hitting the limit costs a dozen-plus seconds.
The five checks run on every read
Whether the numbers are right doesn't rest on "careful arithmetic" but on every error class having a check that turns red. Five checks, ordered by severity:
Check | What it blocks | Consequence if it doesn't |
Filter hit rate | A table's status column spelling changed ("在库" → "在库中") | That table hits zero records, thousands of sticks vanish at once, and every step's conservation stays green (in and out both shrank equally) |
Source table read failure | Can't read some of the tables | Those warehouses' stock vanishes into thin air; people think "it's not there" rather than "it's unknown" |
Bad parts / bad characters | Models with "坏" in them, spec column being | Bad parts get normalized into good ones and counted as usable stock; bad characters pollute material keys |
SN per-record comparison | Can't tell whether "down 106 sticks" is outbound, spelling change, or a misread | Weekly reconciliation relies on guessing |
Model spelling doesn't look like this class (report only, don't block) | A whole batch misclassified into the wrong asset type | Wrong egregiously but all checks green — the 926 optical modules judged as disks: total count right, every step conserved, SN comparison normal, hit rate normal, because the goods are all there, just hung under the wrong class. The only way to find it is a human glancing at the material key list and thinking "why is a disk called |
"Zeroed out" stays in the machine; "how much changed" goes to the model. This line was moved on 2026-08-14: the machine used to also judge "hit rate dropped more than 20 points = clear decline" — that threshold was a guess, marked unverified, and the same change means completely different things in three contexts (a person changed rules / a source column was changed / genuinely new-status goods arrived), and the machine can't tell which. Now the machine only reports {表, 命中率: "99% → 12%", 差几个点}, and whether it's anomalous is judged by the model reading it (看筛选).
A hit rate of zero is a zero-false-positive hard signal: valid rows exist yet nothing hits — that can't be normal business. So it's an absolute criterion, no baseline needed — it was once written as "only report if last hit > 0", which made a table that never hit permanently silent: no baseline on first run so no report, and the second time the baseline also records 0, so the condition never holds. That's exactly the thing this detector exists to prevent. Reporting format:
⚠ 有源表的筛选一条都没命中:
网卡(导入) · 闵行/闵行网卡在库清单信息:读到 4575 行有效数据,但筛选一条都没命中(上次命中 2841 行)
这几乎一定是那张表的状态列写法改了,不是货清空了。去核对源表的筛选字段,别按下面的数字下结论。
**这条会一直报到修好为止** —— 有异常就不滚命中基线,不然警报会把自己吞掉。Alerts must not swallow themselves. The hit-rate baseline (table-hit.json) used to be overwritten on every read, so: a table gets broken → reported once → baseline rolls to "hit 0" → never reported again. Now 该滚基线() only lets it roll when this run is clean; on zero or decline it stays put, and the alert keeps reporting until fixed. The only way out is a human explicitly acknowledging: ./周更.mjs --认下筛选 (not acknowledged with --dry). No auto-acknowledgment path — an alert that can clear itself is no alert at all.
The SN snapshot has the same disease: it's overwritten every read, so the SN对比 in the metrics block compares against "the last time anyone ran this MCP" and almost always shows "一致". The fix on that side is 看变动 reading the weekly baseline (read-only).
Absolute hit rate is not a criterion. Measured: three tables are chronically low-hit with correct rules:
Table | Rule | Actual values in this column |
B-2临港9号楼 · 光模块 (7%) |
| 在线 52,341 · 库房 4,280 · RMA出库 1,330 · 「在线,无对应关系」853 |
JYJY临港9号楼 · 光模块 (15%) | same | 在线 48,777 · 库房 10,575 · 调拨出库 7,910 · 待定 50 |
移动7号楼 · 内存 (20%) |
| 出库 23,689 · 在库 6,031 · 故障 32 |
Those are full-asset ledgers; most rows are devices already racked and in use. Any absolute threshold would false-alarm them.
The fifth: model spelling doesn't look like this class (类不对的键, lib/ledger.mjs)
The criterion is "other classes parse out more core slots than its own", threshold 2, measured not guessed (2026-08-16, 374 real keys + that batch of historical bad keys):
真实键 差 ≤ 0 的 372 个 · 差 1 的 2 个 · 差 ≥ 2 的 0 个
历史错键 硬盘|QSFP112-400G-DR4-SM1310 自己 1 槽位 vs 光模块 5 → 差 4
正常硬盘 硬盘|7.68T NVMe U.2 Gen4 自己 4 槽位 vs 光模块 0 → 差 -4Two tiers are empty in the middle, so 2 has real headroom. Assertions ㉕㉖㉗㉘ + ablation 9, where ㉘ specifically guards "threshold must not be relaxed".
It catches more than one cause: cross-solution dedup assigning goods to the first-seen solution, the solution package's 配件类型 filter being wrong, a table's map column mapping being wrong, someone filling goods under the wrong category in the source table — four phenomena, one shape.
The first version's criterion "own parse yields 0 slots" didn't hold: the disk parser treats 400G as capacity and parses 1 slot, so historical bad keys were all missed. This is exactly "among the four classes only rate's 5 values collide" showing up here — that conclusion was sufficient when counted by spelling variety, but insufficient for "cross-class slot counts"; one collision breaks the condition.
Report only, don't block. It's heuristic, not in the same tier as zero-false-positive hard signals like "filter hit rate zeroed".
Which dimensions, when wrong, can't be detected
The five checks cover the part that "has a second source to cross-check", not everything. When a dimension is wrong, the total still conserves; whether it's discoverable depends on whether the source table offers a second angle on it:
Dimension | What happens when wrong | Is there a second source | Current status |
资产类型 | Whole batch misclassified, total conserved | Yes — model spelling can reverse-infer | The fifth check |
库房 | Whole batch moved warehouses, total conserved | No — the warehouse segment of the material key comes from solution-package config ( | Only config correctness |
品牌 | Whole batch rebranded, total conserved | No — the source table has only one copy | Only guards "two solutions' mapping tables fight" ( |
在库状态 | Over-filtering → stock magically grows | No | Hit rate only catches "filtered to nothing" (zero); over-filtering raises the hit rate, looking healthier |
The last row is the one most worth remembering in this table: "over-filtered" is more insidious than "under-filtered". People are naturally less alert to numbers growing than shrinking, and the machine's only detector happens to be looking in the opposite direction.
Tried using table names as a second source, doesn't work: out of 31 tables, 3 get false positives (a table named B-2项目9号楼 is registered in the PLACE alias table as B-2临港9号楼, two characters off), and a 10% false positive rate turned into an alert on every run just becomes noise.
This API won't tell you what happened
The Feishu multi-dimensional table write API has a consistent style: it says success, but you can't tell what happened from the return value. Hit it five times over 2026-08-15~16; looking at them together is more useful than comments scattered around:
Command | What it doesn't tell you |
| Returns |
| Doesn't check whether the record ID exists. Updating with a nonexistent ID still returns |
| The response body has no table_id, only |
| Only echoes the request body, doesn't validate the shape. Wrong and right both return |
Writing formula fields | Silently swallowed by |
So "must read back after writing, don't trust the return value" isn't conservative in this project — it's the only viable approach.
The 2026-08-16 incident proved both sides: the read-back caught one case (./导台账.mjs re-run reported "values don't match, 0 records"), while the one that crashed before the read-back wasn't caught — so now even write failures go through the full read-back (see lib/ledger-write.mjs).
The request body shape exists in only one place: 创建请求体() / 更新请求体() in lib/ledger-write.mjs, and the contract tests use exactly these two functions. The original tests had a hand-written {update_records:{id:{数量:100}}} in them, so they could catch "lark-cli changed the contract again" but couldn't catch "our code assembled it wrong" — and the 8-16 crash was exactly the adjacent case of the latter (the contract changed, we didn't keep up). Two literal copies each stand on their own, and neither knows when the other changed. What guards this is the two ablations on the write path: swap in a wrong shape for those two production functions, and the criterion must go red (in practice ABLATE=1 reports that day's exact 800010701 Request validation failed) — if someone hand-writes another copy in the tests someday, the ablation stops going red, and run-tests.sh immediately reports "not a single criterion turned it red".
Hitting rate limits: an explicit error, compressed into something you have to guess at
Feishu computes per-minute rate by API × app × tenant, with the narrowest tier at 100 requests/min; hitting it returns HTTP 429 + code 99991400.
This is an explicit error, but by the time it propagates up, all that's left is "this attempt failed", which looks identical to "table can't be read" or "login session expired".
The cost is real: on 2026-08-16 this alone caused four extra regression runs (several minutes each), and every judgment of "is it rate limiting or actually broken" relied on indirect reasoning — the two reds landed at different positions (#74 / #55), and random positions look like an environment issue, whereas a code problem stops at the same place consistently.
Now all three layers recognize it:
Where | What it does |
| On a hit, back off and retry (3s, 8s), making noise to stderr while waiting; if it still hits after backing off, attach a |
| Reports separately: rate-limited says "code is fine, wait a minute and re-run", only a genuine read failure says "check login state and network" |
| Every failed item is rate limiting → exit code 3, |
Three key points, each one earned by stepping on it:
Back off only twice, at most 11 seconds — more isn't better. Initially wrote [3,8,20], then realized it would fight the caller's 120-second timeout — 31 tables each backing off three times could drag a full read round to several minutes, turning "explicit rate limiting" back into "mysterious timeout", exactly the mirror image of the problem this is meant to fix.
"Didn't validate" must not print green. Only exit 3 when every failed item is rate limiting; a single genuine failure means exit 1 — otherwise rate limiting becomes a shield that covers real reds as yellows. The most critical part is the ablation loop: 牙() only looks at "non-zero counts as red", so an ablation that was never even executed still gets counted as a tooth and prints "✓ N/N ablations all went red".
是频控失败() is deliberately narrow. Considered including "timeout" (historically, rate limiting manifested as tools/call hanging for the full 120 seconds), but that would quietly downgrade "the server is genuinely dead" to "this round didn't validate". The cost: if rate limiting manifests as a pure timeout with not a single character left behind, this won't recognize it and will still report red. Better to over-report red.
INVENTORY_FAKE_RATELIMIT=N creates this scenario (the first N calls always return rate-limited), and INVENTORY_BACKOFF_MS compresses the wait to milliseconds. The injection point is at the 退避重试() layer rather than inside call() — injecting inside would mean the ledger-write path can't be injected, and that path only runs about fifty times a year; if it hits, nobody gets a second chance to see the scene.
I initially said this one "can only be validated by actually hitting it once". That was wrong — the same repo already used this pattern twice with
INVENTORY_FAKE_FAILandINVENTORY_FAKE_CHANGED, and the reasoning was even written into my own comments. Having a ready-made solution on hand and not thinking to use it is more worth recording than not knowing.
Filter columns sprouting new values (收筛选取值 / 比取值)
What the hit rate can't catch is the chronic kind: someone changes "在库" to "在库中", old records still use the old wording, and the hit count just drops week by week — zeroing out doesn't trigger, the 20-point threshold is never reached, and by the time it's noticed, weeks have passed. So add a discrete signal: take the raw rows before filtering, and tally the value set of every filter column in every table; report when a value appears that isn't in the baseline. Nearly zero false positives, at the cost of 0.3s extra per full read and a 7.8 KB baseline file.
⚠ 筛选列冒出了没见过的取值:
光模块 · 临港9号楼/B-2临港9号楼 · 资产状态:冒出新取值「在线,无对应关系」853 行
这批行现在没被算进任何一边。如果它其实是「在库」的另一种写法,那批货正在静默丢失;
如果是新的业务状态(借出、待检…),把它加进方案包的筛选规则再跑。Only report new values, not disappeared ones: a status going unused this week is normal. Also don't report when the baseline has no entry for that table/column, otherwise the first run would treat every value as new. Same 该滚基线() governance — if it reports, the baseline doesn't roll, and it keeps reporting until someone handles it.
Read failures no longer throw uniformly; only throw when all source tables are unreadable (that's a login or network problem; half a dataset is meaningless). If only some tables fail, keep going, but a warehouse that's completely unreadable must explicitly occupy a row with null for the count:
{ "库房": "七宝", "根数": null, "读不到": "网卡、硬盘、内存、光模块的表都没读到 —— 这里不是 0 根,是不知道" }Letting it vanish from the array makes people read it as "no stock there" — those two things differ by an order of magnitude. When a table fails, don't write the in-process cache, otherwise the next hot run will treat this incomplete data as good, and since those documents' revisions haven't changed at all, it will keep using it forever.
Create this scenario with INVENTORY_FAKE_FAIL='方案/库房/表名的子串' — without an injection point, this degradation path can never be validated.
Model number normalization: two layers
Layer one, literal (字面指纹 + 挑标准写法): only absorbs differences in case, spaces, hyphens, and periods.
Measured: merges 11 groups, things like CX7-400G ⟷ Cx7 400G, 128G ⟷ 128g, SFP-25G-SR-LC ⟷ SFP.25G-SR.LC.
Slot parsing can't save these — the parser recognizes spec semantics, not typing habits.
This layer's criterion for choosing the standard spelling must not touch counts: more uppercase > more hyphens > longer > lexicographic — all four are properties of the model string itself. The original rule had "the one with more units wins", but counts change every week — the standard spelling is the third segment of the material key, and when it changes, values already selected in the ledger dropdown go dangling.
Layer two, slots: parse out slots like package/rate/standard/medium/wavelength, and merge only when the core slots are all equal. This layer's criteria are extracted at runtime from the Tampermonkey script, not copied.
Four pitfalls on the direct-read path (all blocked in code)
Pitfall | How it's blocked |
The Minhang optical module table reports | All tables only read the columns that |
That table is 84,952 rows × 9 columns; even reading one category exceeds 10MB — the largest block in the whole warehouse (80k+ units) can't be read at all | Read in row segments and concatenate; if a segment is too big, halve it on the spot and retry ( |
The header is rich text: Shanxi optical module's "品牌(必填)" is white "品牌" + red "(必填)" as two segments in the returned structure |
|
The Lingang Unicom table is shared by the NIC/hard-drive/optical-module plans; | Deduplicate in plan-package order, and the dedup count is reported in the caliber block — if it grows, the filter rules need fixing |
The default read returns formula text (the ledger's "物料键" column has 340 rows all being | Always pass |
lark-cli reports errors through two paths, and we only recognized one (established 2026-08-15): |
|
Location is coarse to the warehouse; bin locations don't matter
403库房 / CK2-TEMP / 二楼小仓库 in the source tables are bin locations, 74 kinds, none of which enter the material key — location comes from the source's region (闵行 / 临港9号楼 / 移动7号楼 / 移动45号楼 / 七宝 / 山西 / 临港联通). Bin locations are the warehouse staff's job.
Use the native values interface, not the wrapped +csv-get: the latter silently gives you only part of the data when it exceeds 10MB (ok:true, the data is also good data, just with a has_more:true), and I once computed percentages on a 9.4% sample thinking it covered the whole table. The native interface reports 90221 directly on overflow — silent failure becomes explicit failure.
The ledger itself is pasted in weekly by a person from the Tampermonkey export, not real-time. The "read time" in the caliber block is the moment of these numbers; Feishu's version number stays in meta.revision, and the code uses it to judge whether anything changed — it doesn't stuff it into the caliber block, since to a person it's a string of meaningless digits.
Procurement in transit: already promised, but goods haven't arrived (2026-08-17)
A fourth closed-loop status "采购在途" appeared in the occupancy table. The approach: a person manually adds a row in the material table, leaving brand and warehouse empty (those two segments get decided when the goods arrive), and the occupancy hangs on it. The measured row:
光模块||QSFPDD-400G-DR4 | 在库 0 · 占用 640 · 预计到货 2026-08-31
备注 智设合【2026】年325-004 · 工单 29640The danger isn't the −640 on the books; it's the moment the goods arrive. The goods land on a real key with all four segments produced by the source table (光模块|海光芯创|QSFPDD-400G-DR4-SM1310|闵行), that key's occupancy is 0, and so the same batch of 640 units gets counted twice in two places: the empty key says "promised out", the real key says "available". A person promising based on the real key's number is overselling. The ledger already has 11 real keys for 400G DR4 totaling 607 units — the same order of magnitude as this purchase.
The guard is a check in the opposite direction
挂可用量 originally only checked one direction: source table has it, ledger doesn't (该导没导 / 不进台账的). The reverse direction — "ledger has occupancy, but the source table has no such key" — was completely invisible, and that's exactly where in-transit items land. Now 没挂上的占用 is added, appearing in three places:
Where | Why |
| Placed before the data, not in the caliber block or debug file — it isn't "how this number is computed", it's "shipping against this number will oversell" |
| More fatal here: the conclusion can directly become "no need to purchase" |
| What you should look at weekly is "did the in-transit items arrive"; the cadence is weekly anyway |
No filtering by asset type or model; report everything. These keys are extremely rare (measured: 1 in the whole warehouse), and the cost of a miss is overselling; the filter logic itself becomes a point that's green when it fails — filter it wrong and it silently stops reporting. A few lines of noise in exchange for zero missed reports. Same temperament as "no filter rule matched a single row": keep reporting until someone handles it.
The date follows each row. What a person does upon learning "640 units in transit" is entirely determined by the date — wait for it, or find another way. If it's missing, say plainly "no expected arrival date filled in, ask when it's coming", don't fabricate one: the consequence of fabricating is someone scheduling work against an arrival date that doesn't exist. 预计到货 is a soft requirement in 读占用 (a missing column just means no date), while 物料键 / 占用中 missing means available quantity can't be computed — those are hard; the two don't share a single throw.
The person has exactly one action
When the goods arrive, repoint the "关联物料" on the occupancy record row to the real key. After the change, the empty row becomes 在库 0 / 占用 0, the alert disappears automatically, and those 640 units get correctly deducted on the real key.
The 闭环状态 dropdown is completely ignored by the MCP — it lives on the occupancy record table, while the MCP reads the material table. Changing it is for Feishu's 占用时长 column (🟠等货中 / 🔴等货超期), for people to look at themselves.
Two things with no criteria; you should know them:
A wrong move can't be caught. Only checks "was it moved", not "was it moved correctly". Moving to
QSFP112instead ofQSFPDD, or to the wrong warehouse, is silent — because after the move, that key does have stock in the source table.Split or staggered arrivals require splitting rows. One occupancy record can only point to one material key.
Three things discussed and not done
Why not done | |
Use | Requires reading one more table + ~50 lines; the person decided not to for now |
A separate table for in-transit items | Once a promise is stored across two tables, no single place can answer "what did this work order promise in total" |
Extend the occupancy record table (add model/order number/ETA columns, demote | Formally the cleanest, but |
Weekly update skips rows where "备注 or 预计到货 is non-empty" | Measured it: of 399 rows, 1 matches, and of those, 0 have non-zero 在库 — this rule is empty today. For it to hold, someone would have to manually fill 在库, which would introduce a second owner for |
Ledger: exported weekly, only ever grows
The ledger is the material table of the "配件占用管理表" (multi-dimensional table). The MCP reads source tables to compute 在库, the ledger side computes occupancy, the two sides match on material key, and 可用量 = 实时在库 − 台账占用中.
The ledger only accepts parts that go through the requisition flow (台账范围, defined in lib/ledger.mjs): optical modules / hard drives / NICs / memory. Things that travel with the whole machine and aren't requisitioned by material key aren't accepted — putting them in the ledger just adds a few keys to the dropdown that nobody ever selects. Import filters first, then validates; the log prints "blocked N material keys outside the ledger scope".
This set and the plan packages (plans.json) are currently coincidentally the same width, but the two govern different things: plan packages govern "which source tables the MCP reads", 台账范围 governs "which assets should enter the occupancy ledger". When adding an asset class to the plan packages, judge separately whether it enters the ledger — don't default to following along, or the new class silently enters the ledger, producing a batch of keys nobody selects.
"Key not in the ledger" therefore has two kinds, and 挂可用量 counts them separately and reports them separately in the caliber block. If merged into one number, then as long as any class of "shouldn't be there in the first place" assets exists, it's forever greater than 0; people get used to ignoring it after a few weeks, and when new stock genuinely isn't imported, it's invisible — and they'll waste runs on the unfixable advice "run the ledger import":
Signal | Meaning | What to do |
| In scope, but not in the ledger | Run |
| Out of scope, travels with the machine | Do nothing; its "available quantity" is the in-stock count |
./导台账.mjs --dry # 先看一遍:新增几条、更新几条、置 0 几条
./导台账.mjs # 真写
./周更.mjs # 每周四:重读 → 和上周基线比 → 出报告 → 品牌待补清单 → 存基线 → 写台账In --dry, the most important thing to look at is the "set to 0" rows, and to ask of each one "did this batch disappear, or did it move to a different key".
--dry only reports "原在库 → 0"; it doesn't tell you whether that cell now holds other goods — the latter is what distinguishes "genuinely issued" from "renamed/reclassified". The criterion is to look up the key's "资产类型|品牌|库房" in this week's real-time data: the cell still has other models = likely renamed; the cell is empty = this cell was genuinely cleared.
The 2026-08-14 measurement: of 24 rows set to 0, 19 fell under "硬盘 · 临港联通", yet the model was written like an optical module, e.g. QSFP112-400G-DR4-SM1310 — totaling exactly 926 units, the same batch as the "Lingang Unicom optical modules read as hard drives" issue fixed that day. Setting these to 0 is correct; they're precisely the wrong keys this fix was meant to clear out.
Write rules unchanged, from the ledger-side design doc §3.3: upsert by material key, and for materials that didn't appear in this import, set 在库数量 to 0 rather than deleting the record. After setting to 0 the record still exists, occupancy record keys still match, and the level automatically becomes "待采购" — no stock in the warehouse but still owing people, which is exactly the desired semantics. The code never calls record-delete: deleting leaves occupancy records dangling, and the material side is completely silent (available quantity quietly creeps back up, the level still says "充足", and the data check is empty).
Four gates; if any one fails, the whole batch isn't written (not "skip the bad, write the good"):
Gate | What it blocks |
A source table wasn't read | That warehouse would be treated as "cleared" and everything set to 0, while the goods are all there |
Key invalid (a segment empty / contains a pipe) | An empty key would "absorb all empty-key occupancy", completely silently |
Key ownership doesn't match multiple old keys | This week merged two old keys' spellings into one group; the machine doesn't guess which to keep |
Read-back verification after writing | "API returned ok but the value is empty" is the most expensive error in this table |
Once a key is issued, it's locked (lib/keyreg.mjs)
Occupancy records store the key string, and the key's third segment is a normalized "chosen" standard spelling — measured: of 26 merged groups, 9 were decided by unit count (CX6-25G双口(287) vs CX6-25G*2(158), a single issue can flip it).
After a flip, the old key goes to zero and a new key appears, but the occupancy record still hangs on the old key, and that occupancy can never be settled.
The lock doesn't rely on slot serialization (NICs can't be slot-parsed at all); it uses normalize's existing 原始 field: any newly computed key that shares any raw spelling with an already-issued key is judged to be the same product and reuses the old key.
The criterion is "shared spelling", not "key string identical", so no matter how the standard spelling flips, identity is unaffected. Reusing the old key is reported (the ledger still shows the old spelling, which people will find odd). The registry lives at ~/.cache/inventory-mcp/键注册表.json, and it's only updated after the ledger write succeeds — if the ledger write fails, the key shouldn't count as issued.
Pitfalls stepped on (all measured): +record-list defaults to 100 per page, max 200; without paging, ledger entries beyond 100 get treated as "nonexistent" and re-created as duplicates, while the API returns ok the whole way; +record-delete's --record-id is a repeatable parameter, and passing a space-joined string errors with "Record id must start with rec" even though the ID is valid; the return is columnar (data.data 2D array + data.fields column names), not one fields object per record.
How to fill in missing brands
When the brand column in the source table is empty, there are three ways to fill it in. They differ in reliability, so they're kept separate and reported separately:
Source | Granularity | Where it goes | Reported in the metrics block as |
| Writing-style mapping (Samsung→三星) |
| normalization change |
CMDB export | Verified per-SN |
| brand filled by SN |
Manual batch judgment | Warehouse + asset type |
| brand is a fill-in |
The filled-in values also have to pass through brandAliases — CMDB writes CLT, inventory writes 海光芯创; without that pass, the ledger ends up with two names and two key sets for the same vendor. After normalization there's one more check that throws: if the result still contains any brand that has a standard writing in the lookup table, it errors out and returns nothing.
Anything that can't be filled goes into "brands pending fill-in", and every Thursday a SN list is produced (~/.cache/inventory-mcp/周报/品牌待补-*.csv) to look up in CMDB. SNs are given rather than model numbers, because in CMDB only a per-SN lookup can find a unit's brand.
When the brand passed in the query is wrong: the tool knows the answer, so don't just return a 0
The brand in 查库存 is an exact match (kw(r.brand) === kw(条件.品牌)) — the data side already normalizes with brandAliases when reading the table, so the query side requires the standard name. But the brand the customer says is in English, is abbreviated, is half-typed; when the model doesn't translate it, what comes back is a 0 with no clues at all, indistinguishable from "this batch really has none" — and the latter gets reported to the person as-is. The zero-hit fallback set (which field to relax, the few closest matches) only kicks in when a model or slot is given; a query like {品牌:'Samsung'} never even gets there.
Now when the hit count is 0 and a brand was given, the response body includes an extra "brand" item, with the four cases answered separately (品牌怎么救, in lib/ledger.mjs):
What was passed in | What comes back |
| It's not a brand problem, it's the other conditions — this one is the most likely to be misread as "this brand has no stock" |
| The standard name is 「三星」(N units across the whole library), swap to it and query again |
| Not on the list, but 「海光芯创」 is — candidates come from brands that actually exist in the library, not fabricated |
| Not on the list; the ones with stock in the library are these. The 0-unit ones aren't listed — listing them would steer the model toward an empty result |
The order of the four cases can't be reversed: brandAliases allows self-mapping, so the standard name appears in its own alias list; if aliases are checked first, passing the standard name gets answered as "what you gave is an alias", steering people the wrong way. tests/ledger.test.mjs ㉙ guards this.
Only one copy of the criteria, living in lib/slots.mjs
The logic for deciding "are two writings the same unit of goods" lives in lib/slots.mjs, and lib/ledger.mjs imports it directly.
Before 2026-08-16 it wasn't there — it lived in userscripts/feishu-warehouse-composer/slots.mjs, and the MCP runtime cut out a segment marked by the string export function 建槽位( and ran it via new Function. That whole apparatus (environment-variable path override, slice marker, throw-if-can't-slice) existed only to satisfy the constraint "one copy of the criteria, living on the userscript side".
The constraint is gone, so it moved: that userscript (Feishu warehouse composer) is no longer updated — the Feishu side switched to the official lark-cli interface on 2026-08-11, and the reverse-engineering route is retired. Depending on an unmaintained path is more dangerous than having a second copy: a copy can drift, but a drift can be caught by the criteria; whereas if the path ever disappears, what gets reported is "can't slice out 建槽位", which nobody can make sense of. The userscript-side copy stays there for its own use, and the two sides no longer have a sync relationship — since it's not updated, it won't drift.
A drift in the same-goods criteria won't error out; it only shows up as "inventory doubled" or "the same unit can't be found". Tested for real: QSFP28-100G-SR4 (multimode) and QSFP28-100G-LR4 (singlemode), without the "infer fiber type and wavelength from the standard" step, get judged as the same unit — ship it, plug it in, no light. So it deserves a home of its own rather than living under someone else's roof.
The check in run-tests.sh flipped direction accordingly — it used to check "must read the userscript copy", now it checks three things: the criteria file exists and belongs to this repo, lib/ledger.mjs imports it, and there's no second copy of the slot vocabulary literals outside lib/slots.mjs. It checks the dependency mechanism, not "mentions": the first version grepped for path strings and flagged the comment explaining this history as red — a criterion written too broadly is as bad as one written too narrowly.
The brand lookup table is still outside, living in brandAliases in plans.json (the one edited through the userscript's "brand lookup table" UI, overridable via INVENTORY_PLANS). This side only reads, keeps no copy, and throws if it can't read; if the same writing maps to different brands in two plans, it also throws rather than silently picking one. The difference from the slot criteria is: that one still has people editing it through the UI, so it should stay where it's edited, not be moved in to become a second copy.
Running the tests
./run-tests.sh # 改的过程中跑:23 秒,305 条判据 + 61 个消融,不打网络
./run-tests.sh 全 # 收尾 / 提交前跑一次:89~190 秒(缓存热时 89),打网络的五条链并行
./新旧.sh # 跑着的那个 MCP 是不是最新代码 —— 它是 WorkBuddy 启动时拉起的单例,新开对话不换进程
./自检.mjs # 这套东西能不能跑起来:lark-cli / 登录态 / 方案包 / 真读源表 / 台账 / 三件静态检查
./自检.mjs --快 # 同上,跳过真读源表那步(不打网络)The two tiers were measured out: the 5 network-hitting tests plus their ablations account for 630 of the full suite's 635 seconds, while the 15 non-network tests plus 14 ablation groups total 5 seconds. Change one line of code and wait ten minutes to find out whether it broke — that loop is so long that nobody runs it mid-change — so it only gets run once at the end, which is exactly the latest possible moment to discover a problem.
The full tier runs five chains in parallel (measured 2026-08-17: 624s → 195s, not one criterion or ablation missing). The five main runs were tested individually: 275s serial, 92s parallel, none of them tripped by rate limiting — the backoff-retry done that day absorbed the burst.
The write path chain must run serially, and must not parallelize with itself: it creates tables with same-name prefixes in the production base and cleans up by prefix in finally; two processes running at once will delete each other's tables, and the failure mode is "the table suddenly vanished", which looks like an API error. That whole chain (main run + ablations) runs sequentially in one subprocess, parallel to the other chains.
Output is printed in fixed order, not completion order — output from two runs must be directly diffable. Each chain's seconds are also collected (after parallelization they vanished from the stopwatch once, and "can't measure means can't optimize" is exactly why this tier exists: 624→195 was found by watching the stopwatch).
The default tier's tail explicitly states which items weren't verified and what each one guards. The exit code stays 0 (it did pass what it ran), but running only half must not print as if it ran everything: merging "can't judge" and "passed" into one indistinguishable green is the worst kind of error this system can make.
Shell scripts go to shellcheck, no hand-written regexes (brew install shellcheck; if it's not installed, flag red — silently skipping is the same as always passing). It recognizes the kind this repo actually fell into: 计时="" → SC2276 This is interpreted as a command name containing '='; bash executes it as a command, reports command not found, then keeps going — the variable stays empty while the script finishes green ( bash -n can't catch it; the syntax is legal). Fell into it five times on 2026-08-17.
When adopting it, guard against the second thing: a parse that stops midway doesn't count. Chinese function names (秒() {) are recognized by bash but not by shellcheck; it reports SC1088 at that line and stops parsing, still exiting non-zero — looks like it's working, but actually only 28 of 260 lines were scanned, and the earlier appears unused warnings are all false (it never saw the usage sites). So the function names in run-tests.sh are sec / run_one / teeth / chain rather than Chinese: Chinese function names are legal, but they lock out this repo's only shell linter.
Criterion numbers are generated at runtime (ok #37 …), not hand-written circled characters — ㊱–㊿ ran out at 90 criteria long ago; hand-numbering would inevitably produce duplicates, and FAIL ㊹ wouldn't tell you which one. Adding a criterion requires no number maintenance.
The ablation count is counted, not written in run-tests.sh (tests/消融.mjs). Each test file declares how many ablations it has via 认消融(N), and the suite tries from 1 upward until a test returns exit code 2.
Four exit-code tiers, and the file header of tests/消融.mjs is the only definition; missing any tier means the two corresponding cases can't be told apart:
Code | What it means | Without it, what gets mistaken for what |
0 | All green (during ablation = this ablation is always-green and must be reported) | — |
1 | Some criterion failed | — |
2 | No such ablation number, stop | "This ablation doesn't exist at all" taken as "the ablation is valid" |
3 | Failed, but one item hit Feishu rate limiting → this round wasn't verified | "The network was too busy that minute" taken as "the code is broken"; worse in the ablation loop, an ablation that was never even executed gets counted as a tooth |
3 is not a pass. It makes the whole suite exit non-zero and requires a rerun — so even if a real failure gets mixed into a round and marked ⏸ together, the clean rerun will still print it red; worst case you see it one round later, not never.
This one was learned the hard way: 分段读 added a 3rd ablation while run-tests.sh still had for m in 1 2 — that ablation never ran, yet the summary still printed "✓ 2/2 ablations all turned red" — a new criterion was added, never verified to turn red, while the whole suite was green. It also plugged another one: 决定.test.mjs used to do nothing for unrecognized ablation numbers (if…else if with no else), so ABLATE=3 ran neither the normal path nor the ablation path, all four criteria failed together, and from the exit code it looked exactly like "ablation 3 worked".
Test | Hits network? | What it guards |
| No | Normalization + bad-part exclusion + literal normalization + missing-table fallback + brand supplementation + model numbers that don't look like this category (fifth check) + a word when the brand is passed wrong. 33 assertions |
| No | The direct-read path. 60 assertions: URL parsing, unrecognized URLs must throw for plan packages, location takes "region" not "warehouse", one SN one row, cross-plan dedup, aggregation non-conservation must throw, structure cache validation, cache segment length may only be smaller than the current strategy, lark-cli's two error paths normalized to one, SN per-order comparison, filter hit rate, the whole chain from recognizing rate-limit hits to the suite exit code (including "failures that aren't rate limits are never retried" and " |
| No | Finding substitutes + relaxation cost. 59 assertions: "Did you mean" offered on dead ends (㊺㊻㊼, added 2026-08-24) — when a customer appends a project name after the vendor part number ( |
| No | Weekly update: key validation, three types of weekly diffs, pending list and snapshot alignment, rename explanation (key-level noise that can't be removed turns red), "key zeroed" and "ledger will dangle" kept separate (asserting a dangling normalized key from the disappearance of the raw spelling false-positives once a week). 20 assertions |
| No | Looking at source tables. 13 assertions: columns with different names must be named, tables missing fields must be named, empty mapping treated as "absent" not "named empty", filter rules and header rows given verbatim, when the list is truncated say how many remain |
| No | Scope block slimming. 7 assertions: debug fields not sent by default / all back with |
| No | SN lookup. 19 assertions: multiple raw spellings collapse to the same key, |
| No | Resources / prompt templates / parameter completion, plus |
| No |
|
| No | Recall regression. 189 real model numbers × 9 customer spelling perturbations, runs |
| No | Decisions made by humans. 17 assertions: basis/who decided must not be empty, running twice gives the same state, dedup after normalizing both direction and spelling, retractions keep the previous version, "cannot substitute" decisions must not disappear, bad files must throw but must not take down queries |
| No | The closing line. 10 assertions: one place without quantity, multiple places each with quantity, uses the fewest places, not enough to fill doesn't write "satisfied" and doesn't spread across places, no conclusion without "how many needed", fill by available quantity, deterministic order |
| No | After matching, assemble notification text, return segmented by recipient (doesn't actually send). 9 assertions: split satisfied/shortage/pending (hit keys distinguish "matched but insufficient" from "didn't match inventory", with machine room), asset segment + procurement segment (shortage/pending two segments, only pending doesn't emit shortage segment), normalized model and normalize literal fingerprint same caliber (separator variants don't miss matches) |
| No | Two-step gate of the daily-update MCP tool. 3 assertions: step ② doesn't write without executing, replaying consumed tickets / forged tickets are both invalid (① and real writes need to read/write Feishu ledger, can't test offline; relies on 日更.test.mjs's red computation + manual verification on the real site) |
| No | Two-step gate of the weekly-update MCP tool + whether the SN snapshot is fresh enough. 10 assertions: step ② doesn't write without executing, replay/forged tickets are both invalid; and the |
| No | CSV escaping / BOM / desktop and cache destinations kept separate / old files retired by timestamp (sorting by filename lets last week's |
| No | Statically scan every |
Each one's ablation | Three hit (分段读 / 增量 / 写路径) | Removing any one load-bearing piece of logic must turn red, otherwise the assertion is always green. The count isn't written here — it drifted once when hardcoded; run it and look at the summary row |
| Yes | Tables over 10 MB read in segments + adaptive halving on |
| Yes | Incremental re-read. 6 assertions, only one matters: incremental results must match full results key-for-key on every material key (can't use "conservation passed" as the criterion — reusing a document that actually changed, with total count down a chunk while every step's conservation stays green). Scenarios built via |
| No |
|
| No | Whether hardcoded numbers in the README have drifted. 3 assertions: every no-network test table has a row, the written assertion count matches what runs, every entry script at the repo root is mentioned in the docs. Added because one audit found two new tests never made it into the table — this kind of drift doesn't error, it just turns the docs into something that looks real but doesn't line up |
| No | The |
| No | Records what the model actually asked. 9 assertions: parameters recorded verbatim without filling defaults, failures are recorded too (0 hits and "couldn't run at all" are two different problems), must not slow down queries, monthly rotation keeping only recent months, can be turned off |
| No | Key locking + ledger scope + both directions of occupancy + source table changing spellings wholesale. 27 assertions: standard spelling flip must keep the old key, not carried across warehouses, synthetic groups must report conflicts; "should have been imported but wasn't" and "didn't go through ledger allocation" counted separately (source table has it, ledger doesn't); "ledger has occupancy, source table doesn't have this key" must also be reported (where in-transit lands; missing it means over-issuing), date must follow each entry, not filled means don't fabricate; |
| No | "Calibrate model by SN" cache going stale must be detectable ( |
| No | The main path ( |
| No | Presentation layer ( |
| No | Snapshot + invariants of the contract shown to the model (name/description/parameter table/annotations). The only place in the regression that actually starts a server once — when the tool table moved into |
| No | Dependency direction enforced by the linter ( |
| No | Dead-code ratchet, |
| No | Finding files must not be inferred from "where the code lives" ( |
| No | Who decides the protocol version in the handshake. 12 assertions: if the client's reported version is supported, echo it back; if not, return our latest, never echo (real bug fixed 2026-08-22: it used to unconditionally echo the peer's version, so the peer's compatibility check "is what you returned the same as what I asked" always held and incompatibility was never discovered — the classic green-when-broken). Garbage strings, future versions, empty strings, not reporting at all — all four fall back to our latest; the last assertion verifies the server actually started and a reply was received (if null, the other eleven are void) |
| No | "What's new since the last ledger write" + SN evidence ( |
| No | The rename-judging gate, shared by daily and weekly updates ( |
| No |
|
| Yes | Tables for asset types the read-only path asks about + in-transit dedup. 11 assertions, the most important being reading one type must not roll a single baseline (half the data covering the whole warehouse's SN snapshot; the next full run counts the unread 200k+ parts as all "added", while every step's conservation stays green). Also guards: the caliber must self-report "only read part of the source tables", row-cache stacking doesn't overwrite, the |
| Yes, and actually writes | The contract of the two write-to-multidimensional-table commands. Actually creates a one-off table ( |
| Yes | Whether the initialize / tools/list / tools/call chain works, plus progress notifications (with/without progressToken as mutually corroborating behaviors), SN lookup via file and inline paths, old parameter names must error on the spot, recording decisions does a real disk write (pointed at a temp file). 99 assertions |
Ablation is not for show—it has caught real issues three times: brandMap was originally built as a constant at module load time, so tests changing BRAND couldn't affect it, and ablation 1 stayed green forever; in the direct-read path, the first version of the ablation exempted its own criterion (ABLATE === '1' ? true : ...), so two of the three ablations never turned red; for the literal normalization ablation, the first version picked the wrong samples—using CX7-400G单口 vs Cx7 400G 单口 as samples, which the slot parser could merge on its own, so literal normalization wasn't load-bearing for it. Switching to 128g (lowercase g can't be parsed as capacity) and SFP.25G-SR.LC (the dot can't be parsed as a spec) made it truly exercise only the literal path.
"Ablation not turning red" has two causes, and the second is more insidious: the criterion exempts itself, or the sample never goes through that path at all.
Walking through the rate-limit hit
INVENTORY_FAKE_RATELIMIT=99999 INVENTORY_BACKOFF_MS=1,1 ./run-tests.sh # 该印 ⏸,退 3
./run-tests.sh # 该全绿,退 0Running this in reverse is not a formality—on 2026-08-16 it caught four real problems in one pass, three of which were from the rate-limiting mechanism written that same day:
收尾()originally required "every single hung item to be rate-limited" to count as not verified—but分段读hangs 5 items with only 1 carrying a code, the rest being chain reactions after data became unavailable, so the mechanism built specifically for rate limiting didn't take effect in the face of actual rate limiting.In
direct.test.mjs, the subprocess verifying exit codes passed{...process.env}straight through, carrying the injected variables along with it—a test that never touches the network turned red because of injection elsewhere, the test sandbox had a leak.In
protocol.test.mjs, fourteen bareJSON.parse(…content[0].text)calls—when the tool replies in plain language it throwsUnexpected token '查', "查不了:wiki 换"…—only the first ten characters of the original text remain, whilecode=99991400sits at character 40.写路径.test.mjs's owncli()is a barepexec—when lark-cli exits non-zero, the error body is in stdout, but that object is never read, leaving only aCommand failed:message. If it actually wrote to the production base, it would be exactly the path most likely to hit the limit.
The common thread across all four: they're all "green/yellow when broken", and all of them are only visible when you actually reproduce the scenario.
Not yet done
Occupancy registration is not done (writing to the "occupancy record" table
tbl8GkzD4stgYcay). The material table write is already done (./导台账.mjs), and source tables are strictly read-only. Three hard rules land first: agents can't fill in "verifier", writes carry a request ID with dedup before writing, and a re-read before writing that finds the record already occupied aborts. This table was probed once on 2026-08-15, and four things differ from what was originally recorded:① Occupancy and outbound are two "types" in the same table, not two tables. They use
带符号数量for add/subtract (occupancy+quantity, outbound-quantity), and剩余占用is a formula-computed net amount. The outbound row also needs to point back to the occupancy row it offsets in关联工单(verified: 29124 occupancy +2 → 29154 outbound -2 pointing at it → remaining occupancy 0, status ⚪settled).② Material is already a link field (
关联物料), not text.本行物料键is a formula computed from it, so people pick materials from a dropdown, not type 40 characters by hand—the original note saying "currently text" is outdated.③ "Links silently drop data via API" doesn't hold (verified on 2026-08-15 by writing one and deleting it):
+record-batch-createwith关联物料: [{"id":"rec..."}]writes successfully, the read-back link value matches character for character, and the formula本行物料键actually computed光模块|海光芯创|QSFP112-400G-DR4-SM1310|闵行—the link is live, not a stored empty shell. When the shape is wrong, the API errors explicitly (800010701 Cell value does not match any supported shape), and even gives the correct shape in the hint, rather than silently swallowing it.④ But the response doesn't include record_id:
+record-batch-createreturnsok:truewithrecordsas an empty array, so you can't tell from the return value whether anything was written or what it became. So the rule "must read back to verify after writing, don't trust the return value" is not insurance on this table—it's a requirement. Deleting records requires--yes.There's an existing self-check before writing: this table has a
数据检查formula field with all the rules written into it— missing type / no material selected ("this occupancy can't be charged to anyone") / quantity must be greater than 0 / multiple rows for the same work order and material ("remaining occupancy will be understated") / offset exceeding the occupancy amount / missing date / missing cost attribution / outbound without a linked work order / outbound without a closed-loop status. Read back this field after writing to know if it's correct—don't reimplement the rules in code.The third segment of the material key is still a model string, with only literal-level normalization done. Pure typing differences (case/separators) are already blocked, but semantically identical, literally different variants still each become their own key. The root fix is to replace the key with slot serialization and demote the model string to a display name. Verified: 9 of 26 merge groups have their canonical form decided by cable count (
CX6-25G双口(287)vsCX6-25G*2(158), a difference of 129 cables, reversible in a single outbound), and after a flip, the week-over-week comparison reports false "disappeared + new" entries.NICs get no rule-based substitution (decided 2026-08-15, not a TODO). All three
方向.网卡entries arenull, so its rule file is necessarily empty—whether a CX6 can stand in for a CX5, or a dual-port can stand in for a single-port, is hardware common sense that the slot table can't compute, and the cost of building this table outweighs the benefit. An empty array must carry an explanation: when the不做规则替代table is hit, the return value explicitly states "this category doesn't do rule-based substitution + why", mutually exclusive with "⚠ substitution directions for this category are not yet decided"—the latter reads like a TODO, making people wait for something that will never come. NICs still get exact matches and similar-tier layering as usual. To start on it, delete that entry and fill in方向, changing both places together (lib/substitute.mjs, criteria ㉟㊱㊲ + ablation 9).Quota is not a barrier—already verified. Feishu has no such thing as a "monthly quota"—the official
frequency-controlpage gives per-minute/per-second rates by API × app × tenant, with the narrowest tier at 100 req/min, and the basic and business plan tables are nearly identical. When triggered, it returns HTTP 429 +code 99991400, and the response headerx-ogw-ratelimit-resetdirectly tells you how many seconds to wait. But the direct-read path can hit it: 31 source tables, one cold start is about 48 calls (30 tables one pass each + the Minhang optical module table 1 metadata call + 17 segments), plus one more header pass on an empty cache. Two cold starts within one minute will hit the limit, and after hitting it, the time drops from 8 seconds to 1921 seconds—this isn't theoretical, it was stepped on during the 2026-08-14 investigation into slow table reads, and was mistaken at the time for data volume. Hot state is only 9 calls (checking revisions of 9 documents), and the ledger path is 12 calls. Three people each with their own install doing scattered queries won't hit it, but don't run two full passes within one minute. The finer the segments, the more calls, so do the math before changing每段格子.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Manage your Savanto store from your AI: catalog, content, prompts, and analytics, by chat.
Agent 知识共享市场 — 让 AI Agent 搜索/购买/上传经验记忆。34 个 Tools,支持记忆搜索、购买、上传、评价、团队协作。
Agent-native product catalog for AI shopping agents. 296M+ products, 28 countries.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/320432893-cell/inventory-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server