@manudota/artist-mcp 2.3.1 → 2.3.2-staging.116

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/dist/dispatch.js CHANGED
@@ -115,7 +115,9 @@ export const dispatchWith = (resolve, record = recordWrite) => async (op, params
115
115
  case 'list_notebooks':
116
116
  return (await listNotebooks(token));
117
117
  case 'list_notes':
118
- return (await listNotes(token));
118
+ return (await listNotes(token, {
119
+ section: typeof params.section === 'string' ? params.section : undefined,
120
+ }));
119
121
  case 'map_notes':
120
122
  // The pages are chosen by the caller, which is where the notebook scope
121
123
  // is settled; nothing here maps a notebook it was not given.
package/dist/notes.d.ts CHANGED
@@ -118,11 +118,26 @@ export declare const listNotebooks: (token: string) => Promise<{
118
118
  *
119
119
  * Do not "simplify" this back to the single call.
120
120
  */
121
- export declare const listNotes: (token: string) => Promise<{
121
+ /** How a section name is compared: case and whitespace runs ignored, nothing else. */
122
+ export declare const sectionKey: (name: string) => string;
123
+ /** Graph caps a page listing here, and the walk does not page past it. See #178. */
124
+ export declare const PAGE_LISTING_CAP = 100;
125
+ export declare const listNotes: (token: string, { section }?: {
126
+ section?: string;
127
+ }) => Promise<{
122
128
  notes: NoteSummary[];
123
129
  sections: SectionSummary[];
124
130
  /** Whether `last_modified` on every page is really its creation date. */
125
131
  page_dates_are_creation_dates: boolean;
132
+ /**
133
+ * Every section on the account, by name, when `section` narrowed the walk.
134
+ * The pages of the others were never fetched, so they carry no count — but a
135
+ * name that matched nothing still needs something to be compared against.
136
+ */
137
+ all_sections?: {
138
+ name: string;
139
+ notebook: string | null;
140
+ }[];
126
141
  }>;
127
142
  /**
128
143
  * The sections that changed within a window.
package/dist/notes.js CHANGED
@@ -165,7 +165,11 @@ export const listNotebooks = async (token) => {
165
165
  *
166
166
  * Do not "simplify" this back to the single call.
167
167
  */
168
- export const listNotes = async (token) => {
168
+ /** How a section name is compared: case and whitespace runs ignored, nothing else. */
169
+ export const sectionKey = (name) => name.trim().replace(/\s+/g, ' ').toLowerCase();
170
+ /** Graph caps a page listing here, and the walk does not page past it. See #178. */
171
+ export const PAGE_LISTING_CAP = 100;
172
+ export const listNotes = async (token, { section } = {}) => {
169
173
  const startedAt = Date.now();
170
174
  let sectionCount = 0;
171
175
  let failure = null;
@@ -177,13 +181,22 @@ export const listNotes = async (token) => {
177
181
  '/me/onenote/sections?$select=id,displayName,lastModifiedDateTime' +
178
182
  '&$expand=parentNotebook($select=displayName)&$top=100', token);
179
183
  const sections = (await sectionsRes.json()).value ?? [];
180
- const usable = sections.filter((s) => typeof s.id === 'string' && ONENOTE_ID.test(s.id));
184
+ const valid = sections.filter((s) => typeof s.id === 'string' && ONENOTE_ID.test(s.id));
185
+ // Narrowed before the fan-out, which is the whole saving: one section's pages
186
+ // instead of every section's. Matched on the whole name, not a substring —
187
+ // "BCW" would otherwise walk every BCW project and hand back a page from the
188
+ // wrong one, and the update flow needs exactly one page.
189
+ //
190
+ // Runs of whitespace count as one space: a real section is named
191
+ // "BCW Klagenfurt 03.07.2027 Vidala", and nobody types the second space.
192
+ const wanted = section === undefined ? undefined : sectionKey(section);
193
+ const usable = wanted === undefined ? valid : valid.filter((s) => sectionKey(s.displayName ?? '') === wanted);
181
194
  sectionCount = usable.length;
182
195
  const perSection = await mapWithConcurrency(usable, FANOUT_LIMIT, async (section) => {
183
196
  const res = await graphGet(`/me/onenote/sections/${section.id}/pages` +
184
197
  // createdDateTime is selected only so the two can be compared. It
185
198
  // costs nothing — the request is made either way.
186
- '?$select=id,title,createdDateTime,lastModifiedDateTime&$top=100', token);
199
+ `?$select=id,title,createdDateTime,lastModifiedDateTime&$top=${PAGE_LISTING_CAP}`, token);
187
200
  const pages = (await res.json()).value ?? [];
188
201
  return pages.map((p) => ({
189
202
  id: p.id,
@@ -210,7 +223,18 @@ export const listNotes = async (token) => {
210
223
  // Sections newest first for the same reason, and because when page dates are
211
224
  // useless this is the ordering the caller actually reads.
212
225
  const sectionList = sectionSummaries.sort((a, b) => (b.last_modified ?? '').localeCompare(a.last_modified ?? ''));
213
- return { notes, sections: sectionList, page_dates_are_creation_dates: creationDates };
226
+ if (wanted === undefined) {
227
+ return { notes, sections: sectionList, page_dates_are_creation_dates: creationDates };
228
+ }
229
+ return {
230
+ notes,
231
+ sections: sectionList,
232
+ page_dates_are_creation_dates: creationDates,
233
+ all_sections: valid.map((s) => ({
234
+ name: s.displayName ?? '(unnamed section)',
235
+ notebook: s.parentNotebook?.displayName ?? null,
236
+ })),
237
+ };
214
238
  }
215
239
  catch (err) {
216
240
  failure = err instanceof Error ? err.message : String(err);
package/dist/server.d.ts CHANGED
@@ -10,6 +10,14 @@ import { type Operation } from "./dispatch.js";
10
10
  * that is the point — there is one set of them, not two that drift.
11
11
  */
12
12
  type Dispatch = <T>(op: Operation, params?: Record<string, unknown>) => Promise<T>;
13
+ type NoteSummary = {
14
+ id: string;
15
+ title: string;
16
+ section: string | null;
17
+ /** Absent from responses served by an older edge function. */
18
+ notebook?: string | null;
19
+ last_modified: string | null;
20
+ };
13
21
  type AttachmentBody = {
14
22
  filename: string;
15
23
  mime_type: string;
@@ -58,6 +66,37 @@ type AttachmentMap = {
58
66
  }[];
59
67
  note: string | null;
60
68
  };
69
+ /**
70
+ * The answer to a `section` that matched nothing, or matched more than one.
71
+ *
72
+ * Neither is allowed to fall through to a page list. The update flow resolves a
73
+ * chat message to exactly one page, and an empty list reads as "this project
74
+ * has no page" — which is a real finding for a section like BCW Megeve and a
75
+ * false one for a typo. So the names are offered, closest first, and choosing
76
+ * is left to the user.
77
+ */
78
+ export declare const renderSectionMiss: (section: string, matched: {
79
+ name: string;
80
+ notebook: string | null;
81
+ }[], all: {
82
+ name: string;
83
+ notebook: string | null;
84
+ }[],
85
+ /** Set when the search spanned every notebook: names carry their notebook, and the key to choose one. */
86
+ notebookKey?: string) => string | null;
87
+ /**
88
+ * Which page in a resolved section an update belongs to (#193).
89
+ *
90
+ * Matched on the title's start, not the whole title: the live notebook names
91
+ * them `CL Aufgaben — Montepulciano`, plain `CL Aufgaben`, and
92
+ * `CL Aufgaben — Melk BCW (Barocktage 2027)`, and a whole-title convention
93
+ * would miss two of three. Computed from every page in the section, before
94
+ * `limit` or `since` trim the list, so a capped listing cannot hide it.
95
+ *
96
+ * None and several are both said outright. None is a real finding — the update
97
+ * has nowhere to go, and it must not land on a neighbouring page instead.
98
+ */
99
+ export declare const renderUpdateTarget: (pages: readonly NoteSummary[]) => string;
61
100
  /**
62
101
  * Compose the briefing `list_agent_workflows` returns.
63
102
  *
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { WRITE_CAPABILITIES, isGranted } from "./grants.js";
6
6
  import { listAgentWorkflows, loadAgentWorkflow } from "./agents.js";
7
7
  import { GraphError } from "./client.js";
8
8
  import { call as localCall } from "./dispatch.js";
9
- import { narrowNotes, narrowSections, notebookKeyFor } from "./notes.js";
9
+ import { PAGE_LISTING_CAP, narrowNotes, narrowSections, notebookKeyFor, sectionKey } from "./notes.js";
10
10
  /** One call per page, so a notebook nobody put a number on does not become hundreds of requests. */
11
11
  const DEFAULT_MAP_PAGES = 40;
12
12
  /** Times are stated with their zone; the calendar's zone need not be the reader's. */
@@ -34,7 +34,7 @@ const describeSize = (bytes) => {
34
34
  * intake depends on, and two copies of it would eventually disagree. The caller
35
35
  * names itself so the instruction says which tool to call again.
36
36
  */
37
- const selectNotebook = async (call, notebook, notebookKey, tool) => {
37
+ const selectNotebook = async (call, notebook, notebookKey, tool, section) => {
38
38
  // The notebook question first, and on its own, because it is the cheap one.
39
39
  //
40
40
  // `list_notes` answers it as a side effect of fetching every page of every
@@ -87,7 +87,7 @@ const selectNotebook = async (call, notebook, notebookKey, tool) => {
87
87
  return { message: `No notebook named "${notebook}". Available: ${names.join(", ")}.` };
88
88
  }
89
89
  // Settled. Only now are the pages worth what they cost.
90
- const { notes, sections = [], page_dates_are_creation_dates = false, } = await call("list_notes");
90
+ const { notes, sections = [], page_dates_are_creation_dates = false, all_sections, } = await call("list_notes", section === undefined ? {} : { section });
91
91
  const pages = wanted
92
92
  ? notes.filter((n) => (n.notebook ?? "").trim().toLowerCase() === wanted)
93
93
  : notes;
@@ -115,6 +115,130 @@ const selectNotebook = async (call, notebook, notebookKey, tool) => {
115
115
  sections: inScope,
116
116
  creationDates: page_dates_are_creation_dates,
117
117
  scope,
118
+ allSections: wanted
119
+ ? all_sections?.filter((sec) => (sec.notebook ?? "").trim().toLowerCase() === wanted)
120
+ : all_sections,
121
+ };
122
+ };
123
+ /**
124
+ * The answer to a `section` that matched nothing, or matched more than one.
125
+ *
126
+ * Neither is allowed to fall through to a page list. The update flow resolves a
127
+ * chat message to exactly one page, and an empty list reads as "this project
128
+ * has no page" — which is a real finding for a section like BCW Megeve and a
129
+ * false one for a typo. So the names are offered, closest first, and choosing
130
+ * is left to the user.
131
+ */
132
+ export const renderSectionMiss = (section, matched, all,
133
+ /** Set when the search spanned every notebook: names carry their notebook, and the key to choose one. */
134
+ notebookKey) => {
135
+ if (matched.length === 1)
136
+ return null;
137
+ const label = (sec) => notebookKey === undefined ? sec.name : `${sec.name} (notebook: ${sec.notebook ?? "unknown"})`;
138
+ const callAgain = notebookKey === undefined
139
+ ? "call list_notes again with that section."
140
+ : "call list_notes again with that section, its notebook, and the notebook_key below";
141
+ // On its own line, so no sentence punctuation is ever read as part of it.
142
+ const keyLine = notebookKey === undefined ? "" : `\n\nnotebook_key: ${notebookKey}`;
143
+ if (matched.length > 1) {
144
+ const where = matched.map((sec) => `- ${label(sec)}`);
145
+ return (`${matched.length} sections are named "${section}":\n${where.join("\n")}\n\n` +
146
+ `Ask the user which one they mean, then ${callAgain}. Do not pick one — ` +
147
+ "the same project name in two seasons is two different projects." +
148
+ keyLine);
149
+ }
150
+ // Scored by how many words are shared, and only the best score is offered.
151
+ // Against the real notebook, "any shared word" named every BCW section for a
152
+ // single BCW typo — a list that long is no closer than the full one.
153
+ const needle = sectionKey(section);
154
+ const words = needle.split(" ").filter((w) => w.length > 2);
155
+ const score = (name) => {
156
+ const hay = sectionKey(name);
157
+ if (hay.includes(needle) || needle.includes(hay))
158
+ return words.length + 1;
159
+ return words.filter((w) => hay.split(" ").includes(w)).length;
160
+ };
161
+ const scored = all.map((sec) => ({ sec, s: score(sec.name) }));
162
+ const best = Math.max(0, ...scored.map(({ s }) => s));
163
+ const near = scored.filter(({ s }) => best > 0 && s === best).map(({ sec }) => label(sec));
164
+ return (`No section is named "${section}". ` +
165
+ (near.length > 0
166
+ ? `Closest: ${[...new Set(near)].join(", ")}. `
167
+ : `Sections: ${[...new Set(all.map(label))].join(", ")}. `) +
168
+ `Ask the user which one they mean rather than choosing, then ${callAgain}. ` +
169
+ "Do not read this as the project having no page." +
170
+ keyLine);
171
+ };
172
+ /**
173
+ * Which page in a resolved section an update belongs to (#193).
174
+ *
175
+ * Matched on the title's start, not the whole title: the live notebook names
176
+ * them `CL Aufgaben — Montepulciano`, plain `CL Aufgaben`, and
177
+ * `CL Aufgaben — Melk BCW (Barocktage 2027)`, and a whole-title convention
178
+ * would miss two of three. Computed from every page in the section, before
179
+ * `limit` or `since` trim the list, so a capped listing cannot hide it.
180
+ *
181
+ * None and several are both said outright. None is a real finding — the update
182
+ * has nowhere to go, and it must not land on a neighbouring page instead.
183
+ */
184
+ export const renderUpdateTarget = (pages) => {
185
+ const targets = pages.filter((p) => sectionKey(p.title).startsWith("cl aufgaben"));
186
+ if (targets.length === 1) {
187
+ const [t] = targets;
188
+ return (`CL Aufgaben page in this section: "${t.title}" (id: ${t.id}). ` +
189
+ "An update to this project belongs on this page.");
190
+ }
191
+ if (targets.length === 0) {
192
+ return ("This section has no CL Aufgaben page, so an update to this project has " +
193
+ "no page to go to. Say so; do not write it onto another page in the section.");
194
+ }
195
+ return (`This section has ${targets.length} CL Aufgaben pages: ` +
196
+ targets.map((t) => `"${t.title}" (id: ${t.id})`).join(", ") +
197
+ ". Ask the user which one an update belongs on. Do not pick one.");
198
+ };
199
+ /**
200
+ * `section` with no notebook, on an account holding several: the update flow's
201
+ * case, since "Melk is confirmed" names a project and never a season.
202
+ *
203
+ * Section NAMES are searched across every notebook — they arrive in the one
204
+ * sections call either way, so this costs nothing. PAGES still come back only
205
+ * when exactly one section in exactly one notebook matched, and the reply names
206
+ * that notebook so the answer cannot silently drift into the wrong season.
207
+ * Everything else — two seasons, a partial name — goes back to the user with
208
+ * the notebook_key, the same proof `selectNotebook` asks for.
209
+ */
210
+ const findSectionAcrossNotebooks = async (call, section) => {
211
+ const { notebooks } = await call("list_notebooks");
212
+ // One notebook is not a choice; the ordinary path already handles it.
213
+ if (notebooks.length <= 1)
214
+ return null;
215
+ const names = notebooks.map((n) => n.name);
216
+ const { notes, sections = [], page_dates_are_creation_dates = false, all_sections = [], } = await call("list_notes", { section });
217
+ const miss = renderSectionMiss(section, sections, all_sections, notebookKeyFor(names));
218
+ if (miss !== null)
219
+ return { message: miss };
220
+ const [found] = sections;
221
+ // A unique exact match is not proof of the right season. Found live:
222
+ // "Montepulciano" resolved to 2026-27 while "Montepulciano 2028" sat in
223
+ // 2027-28, and an update naming the festival could mean either.
224
+ const wanted = sectionKey(section);
225
+ const similar = all_sections.filter((sec) => {
226
+ const key = sectionKey(sec.name);
227
+ return key !== wanted && (key.includes(wanted) || wanted.includes(key));
228
+ });
229
+ const others = names.filter((name) => name.trim().toLowerCase() !== (found.notebook ?? "").trim().toLowerCase());
230
+ return {
231
+ pages: notes,
232
+ sections,
233
+ creationDates: page_dates_are_creation_dates,
234
+ scope: `Found in notebook "${found.notebook ?? "unknown"}", the only notebook with a ` +
235
+ `section of this name (others: ${others.join(", ")}). Name that notebook when ` +
236
+ "you answer, so the user can catch a wrong season." +
237
+ (similar.length > 0
238
+ ? " Similarly named, and possibly the project meant: " +
239
+ similar.map((sec) => `${sec.name} (notebook: ${sec.notebook ?? "unknown"})`).join(", ") +
240
+ ". If the update could belong to one of these, ask before using this section."
241
+ : ""),
118
242
  };
119
243
  };
120
244
  /**
@@ -176,7 +300,7 @@ const renderChangedSections = (chosen, notebook, since) => {
176
300
  * the only way OneNote allows a cell to change.
177
301
  */
178
302
  const INDEX_ENTRIES = 12;
179
- const serverVersion = '2.3.1'; // x-release-please-version
303
+ const serverVersion = '2.3.2-staging.116'; // x-release-please-version
180
304
  const errorResult = (err) => {
181
305
  const message = err instanceof GraphError ? err.message : `Unexpected error: ${err}`;
182
306
  return { content: [{ type: "text", text: message }], isError: true };
@@ -349,10 +473,19 @@ const renderWorkflowBriefing = async (entries, load, writes = []) => {
349
473
  "install time. Everything not listed here remains read-only, " +
350
474
  "including all of OneNote.",
351
475
  ...writes.map((name) => `- ${name}: ${WRITE_CAPABILITIES[name]}`),
352
- "A disputed or UNKNOWN value may never be written. If two pages " +
353
- "disagree, or a field is unsettled, refuse the write and say why — " +
354
- "a written value persists and other people see it, which is exactly " +
355
- "the decision policy:divergence refuses to make.",
476
+ // Recording a dispute is not settling one. The CL Aufgaben pages
477
+ // carry a convention for it the field becomes UNGEKLÄRT and both
478
+ // values go under "Widersprüchliche Angaben" and the earlier
479
+ // wording ("may never be written") read as forbidding that too, in
480
+ // the one surface that outranks the playbook saying to do it (#193).
481
+ "A disputed or UNKNOWN value may never be written as though it were " +
482
+ "settled. If two sources disagree, or a field is unsettled, never " +
483
+ "write one side as the value — a written value persists and other " +
484
+ "people see it, which is exactly the decision policy:divergence " +
485
+ "refuses to make. Recording the dispute itself is allowed where the " +
486
+ "page has a place for it: both values with their origins, and the " +
487
+ "field left UNGEKLÄRT or UNKNOWN. Where it has no such place, write " +
488
+ "nothing for that field and say why.",
356
489
  ];
357
490
  return [
358
491
  ...alarm,
@@ -718,6 +851,14 @@ const createServer = async (call, grants = []) => {
718
851
  "notebook you happen to know the user has: a plausible guess here " +
719
852
  "is indistinguishable from their choice and produces a confident " +
720
853
  "answer about the wrong notebook."),
854
+ section: z
855
+ .string()
856
+ .optional()
857
+ .describe("Exact name of one section, to list only its pages. Much cheaper " +
858
+ "than the whole notebook, and the reply gives the section's full " +
859
+ "page count. Use it to find the one page an update belongs to. " +
860
+ "Works without `notebook`: the section is then looked for in every " +
861
+ "notebook, and the reply says which one it was found in."),
721
862
  notebook_key: z
722
863
  .string()
723
864
  .optional()
@@ -745,12 +886,20 @@ const createServer = async (call, grants = []) => {
745
886
  .describe("Cap the number of pages returned, newest first. The reply says how " +
746
887
  "many matched, so a capped list is never mistaken for the whole " +
747
888
  "notebook."),
748
- }, async ({ notebook, notebook_key, since, limit }) => {
889
+ }, async ({ notebook, notebook_key, section, since, limit }) => {
749
890
  try {
750
- const chosen = await selectNotebook(call, notebook, notebook_key, "list_notes");
891
+ const chosen = (section !== undefined && notebook === undefined
892
+ ? await findSectionAcrossNotebooks(call, section)
893
+ : null) ??
894
+ (await selectNotebook(call, notebook, notebook_key, "list_notes", section));
751
895
  if ("message" in chosen) {
752
896
  return { content: [{ type: "text", text: chosen.message }] };
753
897
  }
898
+ if (section !== undefined) {
899
+ const miss = renderSectionMiss(section, chosen.sections, chosen.allSections ?? []);
900
+ if (miss !== null)
901
+ return { content: [{ type: "text", text: miss }] };
902
+ }
754
903
  const selected = chosen.pages;
755
904
  // When page dates carry no modification information, a `since` question
756
905
  // is answered about SECTIONS instead — and answers with sections, not
@@ -774,6 +923,13 @@ const createServer = async (call, grants = []) => {
774
923
  // Narrowed only after the notebook is settled, so a `since` window can
775
924
  // never be what makes a notebook look empty enough to skip choosing.
776
925
  const { notes: shown, matched, undated } = narrowNotes(selected, { since, limit });
926
+ if (shown.length === 0 && section !== undefined && since === undefined) {
927
+ return {
928
+ content: [
929
+ { type: "text", text: `Section "${chosen.sections[0].name}" holds no pages.` },
930
+ ],
931
+ };
932
+ }
777
933
  if (shown.length === 0) {
778
934
  const scope = notebook ? `"${notebook}"` : "this account";
779
935
  return {
@@ -805,6 +961,17 @@ const createServer = async (call, grants = []) => {
805
961
  const caveats = [];
806
962
  if (chosen.scope)
807
963
  caveats.push(chosen.scope);
964
+ if (section !== undefined) {
965
+ caveats.push(renderUpdateTarget(chosen.pages));
966
+ const [sec] = chosen.sections;
967
+ // Stated whether or not it is short, because "has this page seen
968
+ // everything in its section" is answered against this number (#193).
969
+ caveats.push(sec.pages >= PAGE_LISTING_CAP
970
+ ? `Section "${sec.name}" returned ${sec.pages} pages, which is the ` +
971
+ "listing cap: there may be more that were not fetched (#178)."
972
+ : `Section "${sec.name}" holds ${sec.pages} page${sec.pages === 1 ? "" : "s"}; ` +
973
+ "this is all of them.");
974
+ }
808
975
  if (shown.length < matched) {
809
976
  caveats.push(`Showing the ${shown.length} newest by date of ${matched} matching ` +
810
977
  "pages. Raise `limit` or narrow with `since` for the rest.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manudota/artist-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.3.2-staging.116",
4
4
  "description": "MCP server that reads your OneNote notes in Claude Desktop or Codex, with optional Gmail and Calendar as supporting evidence. Signs in on your own machine; writes are opt-in per install.",
5
5
  "keywords": [
6
6
  "mcp",