Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Simplify: aliases are labels (#44)

- **One list.** Search already matched and ranked aliases exactly like labels, so "phished" or "suspicious email" is now simply a label. Migration `026_aliases_into_labels.sql` appends each item's aliases to its labels (lower case, no duplicates; the working row and both published copies, without counting as a new publish), then drops `items.aliases` and the `items_live.aliases` column. Nothing is cut: an item that ends up over the 20-label limit keeps every word, and its next edit asks for fewer.
- **`suggest_labels` keeps working** with `add_labels` / `remove_labels` (plus `reason`, `expected_revision`); `add_aliases` and `remove_aliases` are gone. Item output has no `aliases:` line, the edit form no Aliases field, the item page no "Also searched as" line, and Markdown export no Aliases line.

### Simplify: one review date, no per-library freshness settings (#44)

- **`review_by` is the only review date.** Policy items no longer have a **Review date** field (or its "on or after the effective date" check), so the Policy starter has 10 fields. Migration `025_single_review_date.sql` copies the published Review date into `review_by` where none was set, and removes `review_date` from Policy items (working row and both published copies, without counting as a new publish) and from Policy library schemas. "Still accurate" and the Policy page can no longer disagree. Agents that still send `review_date` get "Unknown field(s): review_date".
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ MCPEncode keeps the facts your IT team's AI agents rely on (help desk runbooks,

- **Citations with version.** Every answer names the published version, date, publisher and link, so you can see where an answer came from.
- **Freshness.** Policy and Procedure items get a review-by date 90 days after each publish; publishers can set one on any item. Agents relay a warning naming the owner when an item is past review. A **Review queue** lists what is due, with one-click **Still accurate**.
- **Aliases.** "We got phished" finds the Incident Response runbook. Agents can suggest labels and aliases with `suggest_labels`; a person approves them like any other edit.
- **Search words as labels.** Label the Incident Response runbook "phished" and "We got phished" finds it. Agents suggest labels with `suggest_labels`; a person approves them like any other edit.

**Status:** Early (0.2). Issues welcome; please open one before a large pull request.

Expand Down Expand Up @@ -45,14 +45,14 @@ The client opens a browser: sign in, then **Allow** on the consent page. That co

## Screenshots

All data below is fictional demo content (`npm run seed-demo`). Left to right: the Libraries home; a published security Policy; a Pending edit (agents keep the published version); approving hosted apps; the **Review queue** for help desk runbooks; an agent's **label and alias suggestion** waiting for review; a runbook with its **review-by date** and **aliases**; the consent page.
All data below is fictional demo content (`npm run seed-demo`). Left to right: the Libraries home; a published security Policy; a Pending edit (agents keep the published version); approving hosted apps; the **Review queue** for help desk runbooks; an agent's **label suggestion** waiting for review; a runbook with its **review-by date** and labels; the consent page.

| | |
| --- | --- |
| <img src="docs/assets/landing.png" alt="Libraries list with demo Libraries for IT policies, help desk runbooks, customer support, a biology course, product docs, HR and sales" width="440" /> | <img src="docs/assets/item.png" alt="A published Policy Item with owner, version and effective date" width="440" /> |
| <img src="docs/assets/pending-changes.png" alt="An Item with a Pending edit: banner says agents still see the published version, with a diff of what changed" width="440" /> | <img src="docs/assets/apps.png" alt="Settings, Apps: one hosted app waiting for approval and one approved" width="440" /> |
| <img src="docs/assets/review-queue.png" alt="Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate" width="440" /> | <img src="docs/assets/label-suggestion.png" alt="An agent's suggested labels and aliases for the Incident Response Runbook, Pending for a person to Publish or Cancel" width="440" /> |
| <img src="docs/assets/item-freshness.png" alt="VPN Troubleshooting runbook with its review-by date, Still accurate button and aliases (remote access, office network, work from home)" width="440" /> | <img src="docs/assets/consent.png" alt="Allow access page asking whether an app may use this account for one Library" width="440" /> |
| <img src="docs/assets/review-queue.png" alt="Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate" width="440" /> | <img src="docs/assets/label-suggestion.png" alt="An agent's suggested labels for the Incident Response Runbook, Pending for a person to Publish or Cancel" width="440" /> |
| <img src="docs/assets/item-freshness.png" alt="VPN Troubleshooting runbook with its review-by date, Still accurate button and search labels" width="440" /> | <img src="docs/assets/consent.png" alt="Allow access page asking whether an app may use this account for one Library" width="440" /> |

## Known limitations

Expand Down
18 changes: 9 additions & 9 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Use exactly these words in UI text, docs and code. Do not invent synonyms (vault
| Word | Meaning |
| --- | --- |
| **Library** | A named set of items with one schema, one Connect link and one Access list. There are many libraries, not one global pile. |
| **Item** | One entry in a library: name, labels, aliases, summary, and a markdown body or template fields. |
| **Aliases** | Other words people use for an item ("phished", "suspicious email"). Search matches them like labels. Not shown as tags. |
| **Item** | One entry in a library: name, labels, summary, and a markdown body or template fields. |
| **Labels** | Free-form tags: categories ("security") and the other words people search with ("phished", "suspicious email"). |
| **Publish** | A person approves an item. Readers and Reader keys only ever see published items. |
| **Connect link** | A library's MCP address, with a secret token. One link reaches one library. The token is stored only as a SHA-256 hash, so the full link is shown once; to get a new one, rotate it. |
| **Reader** / **Publisher** | Per-library roles, granted under **Access**. |
Expand Down Expand Up @@ -55,7 +55,7 @@ Each Connect link is one library. Tools offered depend on the caller's role in t
| `search_shelf` | Reader+ | Published summaries across the library's shelf |
| `create_item` | Publisher | Save: Pending (Review Lock) or published (Review Unlock). With an `entity_key` that already exists in the library, updates that item instead (upsert). |
| `update_item` | Publisher | Change an item. Fields you leave out keep their value; `replace: true` replaces the whole item (omitted optional fields are cleared). Unknown fields are refused. |
| `suggest_labels` | Publisher | Add or remove labels and aliases on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) |
| `suggest_labels` | Publisher | Add or remove labels on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) |
| `delete_item` | Site admin | Hard-delete an item (Publishers archive instead) |
| `create_library` | Site admin | Create a library, optionally with a proposed table |
| `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema. After approval the tools pick up the new field on the next request; there is no need to reconnect. |
Expand All @@ -71,17 +71,17 @@ Each Connect link is one library. Tools offered depend on the caller's role in t
- **No match** from `list_items` (by `q` or `entity_key`) is a normal answer that says the item is not in the library, not a tool error.

**How `list_items q=` searches.**
1. Every word must match (name, labels and aliases, summary, then body; the full-text index applies English stemming). Aliases score exactly like labels.
1. Every word must match (name, labels, summary, then body; the full-text index applies English stemming).
2. If nothing matches every word, any word may match. A clear winner that matched on its name or labels, and on most of the words, is returned as the one answer. Weaker candidates come back as **"Did you mean"** suggestions, which the agent must open and check before citing.
3. Still nothing: words that appear nowhere in the library's names, summaries, labels or aliases are corrected to the closest library word (1–2 typos), and steps 1–2 run again.
3. Still nothing: words that appear nowhere in the library's names, summaries or labels are corrected to the closest library word (1–2 typos), and steps 1–2 run again.

A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary, labels or aliases".
A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary or labels".

**Aliases and label suggestions.** Each item has an editable **Aliases** field (comma-separated, lower case, up to 20 of 40 characters). Aliases are part of the published copy, so changing them is an ordinary edit: under Review Lock it goes Pending and Readers keep searching the published aliases until Publish. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, `add_aliases`, `remove_aliases`, optional `reason` and `expected_revision`):
**Label suggestions.** Labels double as search words: label an incident runbook "phished" and "we think we got phished" finds it. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, optional `reason` and `expected_revision`):
- It only works on published items (including ones with a Pending edit, which it joins). Name, summary and body are never changed; use `update_item` for drafts.
- **Review Lock:** the item goes Pending. "What changed" lists **Labels** and **Aliases** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels and aliases.
- **Review Lock:** the item goes Pending. "What changed" lists **Labels** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels.
- **Review Unlock:** the change is live at once.
- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels and aliases, reason). Approve and reject are logged by the existing publish and cancel events.
- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels, reason). Approve and reject are logged by the existing publish and cancel events.
- Citations follow the published copy: the version moves only when a suggestion is published (or saved under Review Unlock).

**Citations.** Every published item that `list_items`, `open_item` or `search_shelf` returns carries a citation for its **published** copy (never a Pending draft). A full item ends with:
Expand Down
94 changes: 94 additions & 0 deletions migrations/026_aliases_into_labels.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- Aliases fold into labels. Search already matched and ranked aliases exactly
-- like labels, and both went through Review Lock the same way, so one list
-- does the job: "phished" or "suspicious email" is simply a label.
-- Each item keeps its labels first, then any alias it did not already have
-- (lower case, trimmed, first-seen order). Nothing is dropped: an item that
-- ends up over the 20-label limit keeps every word, and the next edit asks
-- for fewer. The published copies get the same merge without counting as a
-- new publish.

CREATE OR REPLACE FUNCTION mcpencode_merge_labels(labels JSONB, aliases JSONB) RETURNS JSONB
LANGUAGE sql IMMUTABLE AS $$
SELECT COALESCE(jsonb_agg(label ORDER BY first_pos), '[]'::jsonb)
FROM (
SELECT label, MIN(pos) AS first_pos
FROM (
SELECT lower(btrim(value)) AS label, pos
FROM jsonb_array_elements_text(
(CASE WHEN jsonb_typeof(labels) = 'array' THEN labels ELSE '[]'::jsonb END) ||
(CASE WHEN jsonb_typeof(aliases) = 'array' THEN aliases ELSE '[]'::jsonb END)
) WITH ORDINALITY AS t(value, pos)
) cleaned
WHERE label <> ''
GROUP BY label
) merged
$$;

UPDATE items SET labels = mcpencode_merge_labels(labels, aliases)
WHERE jsonb_typeof(aliases) = 'array' AND jsonb_array_length(aliases) > 0;

ALTER TABLE items DISABLE TRIGGER items_published_version;
ALTER TABLE items DISABLE TRIGGER items_review_by;

UPDATE items
SET published_snapshot = (
jsonb_set(published_snapshot::jsonb, '{labels}',
mcpencode_merge_labels(published_snapshot::jsonb -> 'labels', published_snapshot::jsonb -> 'aliases')) - 'aliases'
)::text
WHERE published_snapshot IS NOT NULL
AND pg_input_is_valid(published_snapshot, 'jsonb')
AND jsonb_typeof(published_snapshot::jsonb) = 'object'
AND published_snapshot::jsonb -> 'aliases' IS NOT NULL;

UPDATE items
SET previous_published_snapshot = (
jsonb_set(previous_published_snapshot::jsonb, '{labels}',
mcpencode_merge_labels(previous_published_snapshot::jsonb -> 'labels', previous_published_snapshot::jsonb -> 'aliases')) - 'aliases'
)::text
WHERE previous_published_snapshot IS NOT NULL
AND pg_input_is_valid(previous_published_snapshot, 'jsonb')
AND jsonb_typeof(previous_published_snapshot::jsonb) = 'object'
AND previous_published_snapshot::jsonb -> 'aliases' IS NOT NULL;

ALTER TABLE items ENABLE TRIGGER items_published_version;
ALTER TABLE items ENABLE TRIGGER items_review_by;

DROP FUNCTION mcpencode_merge_labels(JSONB, JSONB);

-- The view loses its aliases column (CREATE OR REPLACE cannot drop one).
DROP VIEW IF EXISTS items_live;
CREATE VIEW items_live AS
SELECT
i.id,
i.library_id,
i.entity_key,
CASE WHEN i.status = 'draft' AND i.published_live
THEN (i.published_snapshot::jsonb ->> 'name') ELSE i.name END AS name,
CASE WHEN i.status = 'draft' AND i.published_live
THEN COALESCE(i.published_snapshot::jsonb -> 'labels', '[]'::jsonb) ELSE i.labels END AS labels,
CASE WHEN i.status = 'draft' AND i.published_live
THEN (i.published_snapshot::jsonb ->> 'summary') ELSE i.summary END AS summary,
CASE WHEN i.status = 'draft' AND i.published_live
THEN (i.published_snapshot::jsonb ->> 'body') ELSE i.body END AS body,
CASE WHEN i.status = 'draft' AND i.published_live
THEN (i.published_snapshot::jsonb ->> 'details') ELSE i.details END AS details,
CASE WHEN i.status = 'draft' AND i.published_live
THEN 'published' ELSE i.status END AS status,
i.published_snapshot,
i.previous_published_snapshot,
(i.status = 'draft' AND i.published_live) AS has_pending_changes,
i.created_by,
i.updated_by,
i.created_at,
CASE WHEN i.status = 'draft' AND i.published_live
THEN COALESCE((i.published_snapshot::jsonb ->> 'published_at')::numeric::bigint, i.created_at)
ELSE i.updated_at END AS updated_at,
CASE WHEN i.status = 'draft' AND i.published_live
THEN
setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'name', '')), 'A') ||
setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'summary', '')), 'B') ||
setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'body', '')), 'C')
ELSE i.search_tsv END AS search_tsv
FROM items i;

ALTER TABLE items DROP COLUMN IF EXISTS aliases;
Loading
Loading