ofw-mcp 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -0
- package/dist/auth-password.js +7 -3
- package/dist/bundle.js +6728 -2203
- package/dist/client.js +4 -1
- package/dist/index.js +1 -1
- package/dist/protocol.js +35 -0
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +36 -3
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'path';
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { resolveAuth } from './auth.js';
|
|
6
6
|
import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
|
|
7
|
-
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
7
|
+
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS, assertOfwUrl } from './protocol.js';
|
|
8
8
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
9
9
|
// bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
|
|
10
10
|
// missing dotenv module. The try/catch additionally guards a runtime where
|
|
@@ -163,6 +163,9 @@ export class OFWClient {
|
|
|
163
163
|
if (body !== undefined && !isFormData)
|
|
164
164
|
headers['Content-Type'] = 'application/json';
|
|
165
165
|
const url = `${BASE_URL}${path}`;
|
|
166
|
+
// Egress allowlist — checked before the debug log, so a URL we refuse is
|
|
167
|
+
// never announced as outgoing.
|
|
168
|
+
assertOfwUrl(url);
|
|
166
169
|
if (debugLogEnabled()) {
|
|
167
170
|
const bodyPreview = body === undefined
|
|
168
171
|
? '<none>'
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
36
36
|
// always succeeds before any credential check runs.
|
|
37
37
|
await runMcp({
|
|
38
38
|
name: 'ofw',
|
|
39
|
-
version: '2.
|
|
39
|
+
version: '2.16.0', // x-release-please-version
|
|
40
40
|
deps: client,
|
|
41
41
|
tools: [
|
|
42
42
|
registerHealthcheckTools,
|
package/dist/protocol.js
CHANGED
|
@@ -15,3 +15,38 @@ export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
15
|
// How early we treat a token as expiring. Re-auth before this skew so a
|
|
16
16
|
// long-running request doesn't get a stale token mid-flight.
|
|
17
17
|
export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
18
|
+
// The only host this server is ever allowed to contact. Derived from BASE_URL
|
|
19
|
+
// so the two cannot drift.
|
|
20
|
+
const ALLOWED_HOST = new URL(BASE_URL).host;
|
|
21
|
+
/**
|
|
22
|
+
* Enforced egress allowlist: every outbound request passes its
|
|
23
|
+
* fully-constructed URL through here before `fetch` (API calls in client.ts,
|
|
24
|
+
* the form login in auth-password.ts).
|
|
25
|
+
*
|
|
26
|
+
* Today this always passes, and that is the point. Every URL is
|
|
27
|
+
* `${BASE_URL}${path}` with a path that begins with '/', so no id or query
|
|
28
|
+
* value interpolated into a path can move the host — an '@' can never land in
|
|
29
|
+
* the authority component when a '/' already precedes it. The check exists so
|
|
30
|
+
* that stays true after a future refactor: a code change (or a compromised
|
|
31
|
+
* dependency) that pointed `fetch` somewhere else would throw here instead of
|
|
32
|
+
* carrying the bearer token or a message body off-host. It makes "this server
|
|
33
|
+
* only ever talks to OFW" a structural invariant rather than a code-review
|
|
34
|
+
* promise.
|
|
35
|
+
*
|
|
36
|
+
* Scope, stated honestly: this is a PRE-FLIGHT check on the URL we construct.
|
|
37
|
+
* It does not follow redirects, so it is not a complete egress control — an
|
|
38
|
+
* OFW-served 3xx to another host is still followed by `fetch` (which does at
|
|
39
|
+
* least drop Authorization on a cross-origin redirect, per the fetch spec).
|
|
40
|
+
*/
|
|
41
|
+
export function assertOfwUrl(rawUrl) {
|
|
42
|
+
let host;
|
|
43
|
+
try {
|
|
44
|
+
host = new URL(rawUrl).host;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`ofw-mcp: refusing malformed request URL "${rawUrl}"`);
|
|
48
|
+
}
|
|
49
|
+
if (host !== ALLOWED_HOST) {
|
|
50
|
+
throw new Error(`ofw-mcp: refusing request to non-OFW host "${host}" — only ${ALLOWED_HOST} is allowed.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
6
6
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "^26.0.0",
|
|
45
|
-
"@vitest/coverage-v8": "^
|
|
45
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
46
46
|
"esbuild": "^0.28.0",
|
|
47
47
|
"typescript": "^7.0.2",
|
|
48
|
-
"vitest": "^
|
|
48
|
+
"vitest": "^5.0.0"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.16.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.16.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -92,11 +92,11 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
92
92
|
|------|-------|
|
|
93
93
|
| `ofw_sync_messages(folders?, deep?, fetchUnreadBodies?)` | Sync OFW → local cache. **Call first if the cache might be stale.** Returns unread inbox hints (bodies not fetched, to avoid mark-as-read). |
|
|
94
94
|
| `ofw_list_message_folders` | List OFW folders with unread counts. Most reads use the cache; this is mainly for folder IDs and live unread counts. |
|
|
95
|
-
| `ofw_list_messages(folderId?, since?, until?, q?, sort?, page?, size?, autoRefresh?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. `sort:"oldest"` starts at the old end of a range instead of paging to it (default `"newest"`). Returns `complete` for the RESULT SET plus **`nextPage`** (null when done) — and the paging keys come FIRST in the JSON, before `messages`. `returned` carries the record count as a scalar, beside `total`. An **empty** result from a non-fresh cache is refused (`UNVERIFIED_EMPTY`) — pass `autoRefresh:true` to sync and answer instead. |
|
|
96
|
-
| `ofw_get_message(messageId, allowMarkRead?)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
|
|
95
|
+
| `ofw_list_messages(folderId?, since?, until?, q?, sort?, page?, size?, autoRefresh?, view?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. `sort:"oldest"` starts at the old end of a range instead of paging to it (default `"newest"`). Returns `complete` for the RESULT SET plus **`nextPage`** (null when done) — and the paging keys come FIRST in the JSON, before `messages`. `returned` carries the record count as a scalar, beside `total`. An **empty** result from a non-fresh cache is refused (`UNVERIFIED_EMPTY`) — pass `autoRefresh:true` to sync and answer instead. |
|
|
96
|
+
| `ofw_get_message(messageId, allowMarkRead?, view?)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
|
|
97
97
|
| `ofw_send_message(draftId?, subject?, body?, recipientIds?, replyToId?, expectedRevision?, deleteDraftOnSuccess?, myFileIDs?, force?)` | Send a message — **the one irreversible operation**. Preferred path: pass `draftId` (+ `expectedRevision`) to send an existing draft **as it exists on the server** — the tool re-reads it from OFW first and refuses if it changed since you read it (or was already sent/deleted); `subject`/`body` become optional overrides. `recipientIds` is usually still required: OFW does not store recipients on drafts. After a **confirmed** send the draft is auto-deleted (`deleteDraftOnSuccess:false` to keep it); on any failure or ambiguity it is retained and the response says why (`draftRetained`). Response leads with `sentMessageId`, `draftKey`, `threaded`, `draftDeleted`. Compose from scratch by passing `subject`/`body`/`recipientIds` with no `draftId`. |
|
|
98
98
|
| `ofw_get_unread_sent(page?, size?, autoRefresh?)` | Sent messages your co-parent hasn't read yet (from cache). Leads with `complete`/`hasMore`/`nextPage`, then `scanned`/`total`, then `unread`; an empty sent cache that is not fresh is refused rather than reported as "nothing sent". |
|
|
99
|
-
| `ofw_list_drafts(page?, size?, verify?, autoRefresh?)` | Leads with `complete`/`hasMore`/`nextPage`; `drafts` comes last. List saved drafts, **auto-verified**: when the cache is not verified-fresh a cheap drafts sync runs first (default `verify:true`), so one call answers server-confirmed. `verify:false` serves straight from cache. Each draft carries `serverConfirmed`, `revision` and `draftKey`. Returns `complete` — **check it before saying "you have N drafts"**. See [Freshness](#freshness). |
|
|
99
|
+
| `ofw_list_drafts(page?, size?, verify?, autoRefresh?, view?)` | Leads with `complete`/`hasMore`/`nextPage`; `drafts` comes last. List saved drafts, **auto-verified**: when the cache is not verified-fresh a cheap drafts sync runs first (default `verify:true`), so one call answers server-confirmed. `verify:false` serves straight from cache. Each draft carries `serverConfirmed`, `revision` and `draftKey`. Returns `complete` — **check it before saying "you have N drafts"**. See [Freshness](#freshness). |
|
|
100
100
|
| `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?, expectedRevision?, force?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response leads with `draftKey`, which stays the same across every edit — **track that, not the id**. Note: OFW does **not** store recipients on drafts — `recipientIds` are accepted but come back empty (a one-line NOTE says so; supply them at send time instead). Threading warnings fire only on genuine drops — a draft echoing `inReplyTo`/`showContext` IS threaded. |
|
|
101
101
|
| `ofw_delete_draft(messageId)` | Delete a draft. |
|
|
102
102
|
| `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
|
|
@@ -125,6 +125,39 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
125
125
|
| `ofw_list_journal_entries(start?, max?)` | 1-based offset; default max 10 |
|
|
126
126
|
| `ofw_create_journal_entry(title, body)` | Create a new entry |
|
|
127
127
|
|
|
128
|
+
## Response shape (`view`)
|
|
129
|
+
|
|
130
|
+
`ofw_list_messages`, `ofw_get_message` and `ofw_list_drafts` take
|
|
131
|
+
`view: "compact" | "full"`, and **`compact` is the default** — you get the slim
|
|
132
|
+
shape without asking. It measured 135.1 KB → 41.1 KB on a real 50-message page.
|
|
133
|
+
|
|
134
|
+
What compact drops is `listData`, OFW's echo of the list payload. That blob was
|
|
135
|
+
58% of the response and 78% of it duplicated fields the same object already
|
|
136
|
+
emits at the top level — eleven pre-formatted renderings of one timestamp,
|
|
137
|
+
eight fields per recipient beside the three the row already normalised, and a
|
|
138
|
+
`preview` that is a truncation of the `body` in the same object.
|
|
139
|
+
|
|
140
|
+
**Two things about compact that will surprise you if you don't know them:**
|
|
141
|
+
|
|
142
|
+
- **The sender is `from`, not `fromUser`.** `fromUser` is the empty string on
|
|
143
|
+
every row — inbox and sent alike — because OFW names the sender only inside
|
|
144
|
+
`listData.author`. So compact promotes it. Dropping `listData` without that
|
|
145
|
+
would have taken the sender off every message; this is the one field the fat
|
|
146
|
+
blob was carrying alone.
|
|
147
|
+
- **On a draft, `ofw_get_message` returns `from: null`** — a draft is unsent, so
|
|
148
|
+
there is no sender, and `""` would read as one we failed to find.
|
|
149
|
+
|
|
150
|
+
Pass `view: "full"` when you need a field compact dropped — `listData` and
|
|
151
|
+
everything in it. There is deliberately **no `raw` rung**: a message here is
|
|
152
|
+
assembled from a list item, a detail GET and derived fields, so there is no
|
|
153
|
+
single upstream payload to hand back, and a rung that skipped normalisation
|
|
154
|
+
would put naive local times back beside UTC ones on exactly the rung you reach
|
|
155
|
+
for when something already looks wrong.
|
|
156
|
+
|
|
157
|
+
`ofw_get_unread_sent` takes no `view`: it emits a verdict list
|
|
158
|
+
(`{id, subject, sentAt, unreadBy}`) that is already narrower than the
|
|
159
|
+
projection.
|
|
160
|
+
|
|
128
161
|
## Freshness, completeness, and lifecycle
|
|
129
162
|
|
|
130
163
|
Message and draft reads come from a local cache, so **a result can be stale without looking stale**. Three separate questions, three separate signals — do not substitute one for another:
|