@nooticr/mcp 1.26.66 → 1.26.68

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/README.md CHANGED
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 108 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 117 tools, grouped by what you are trying to do. Prices are in nooticr credits and
56
56
  match what the server actually charges.
57
57
 
58
58
  The job tools under **Answer a question you actually have** are not
@@ -111,6 +111,12 @@ them caps that fan-out with an argument.
111
111
  | `creative_image` | quoted first | 1-4 still images from a `prompt` with GPT Image (low quality), at a `size` (default portrait 1024x1536), optionally `transparent`. The first call returns a quote in credits and charges nothing; call again with `confirm: true` and the `confirmationToken` to generate. Settled to actual usage, never above the quote. Returns the new `assets` and `creativeCredits`. Keep long text out of the prompt and ask for invented people, not real ones. |
112
112
  | `creative_image_edit` | quoted first | Edit or combine 1-4 existing image assets (`imageAssetIds`) with a `prompt`, with an optional `maskAssetId`. Same quote-then-confirm handshake as `creative_image`; returns a new asset and `creativeCredits`. |
113
113
  | `creative_job_status` | free | Where an asynchronous creative job stands by `jobId`: queued, running, done, failed or canceled, with the finished assets and the credits quoted, charged and refunded. Poll after `pollAfterSeconds`. |
114
+ | `sheets_create` | free | Create a Google spreadsheet in the connected Google account, with named tabs and a header row on each. Returns its id and URL; Nooticr owns the file, so later writes need no approval. Needs the product's Google Sheets connection. |
115
+ | `sheets_list` | free | The spreadsheets the connection can see (created by Nooticr or picked by the customer) and whether each may be edited without approval. |
116
+ | `sheets_read` | free | Read an A1 range (e.g. `Ledger!A1:F200`) of a spreadsheet; at most 10000 cells. |
117
+ | `sheets_append_rows` | free | Append typed rows after the last row of a tab. Free on files Nooticr created or the customer allowed you to edit; any other picked file waits for approval. |
118
+ | `sheets_update_range` | free | Overwrite an A1 range with typed values. Same approval rule as `sheets_append_rows`. |
119
+ | `sheets_add_sheet` | free | Add an empty tab to a spreadsheet. Same approval rule as `sheets_append_rows`. |
114
120
  | `mention_trend` | free | **What a watch has seen over time**, which nothing else here answers — every other read is about now. One point per run: when it ran, how many mentions it found, how many were new, and both counts per network, so "which network is growing" and "did it go quiet or did we stop looking" come apart. On a competitor watch each point carries the median that run measured, which turns "they beat their own median" into "and that median has doubled". Plus the mentions more than one run has seen. Free because the sweeps were billed when they ran; charging again to read what was already paid for is the thing it fixes. |
115
121
  | `update_watch_portfolio` | free | Add or remove a portfolio watch's terms by `watchId`. No new confirmation, because the per-run budget you agreed to does not move — more terms split it more ways, and the reply names any network a run no longer reaches. |
116
122
  | `stop_brand_watch` | free | Stop a watch by `watchId` or `term`. Immediate — the next run does not happen and nothing more is charged. |
@@ -121,8 +127,11 @@ them caps that fan-out with an argument.
121
127
 
122
128
  | Tool | Credits | What it is for |
123
129
  |------|---------|----------------|
124
- | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply. This tool never sends anything; a reply is posted only if you then ask for it, on your connected account, after you confirm the exact text. `limit` caps how many posts are opened, which is the price. |
130
+ | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply itself: the drafts are for a person to paste in, or to send through `draft_comment_reply` and `post_comment_reply` once the text is confirmed. `limit` caps how many posts are opened, which is the price. |
125
131
  | `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
132
+ | `list_comments_to_answer` | free | Recent comments on your *own* connected account that nobody has answered yet, newest first (YouTube once connected with `capabilities: ["manage_comments"]`; other networks come back under `unsupported` with the reason). Read-only. |
133
+ | `draft_comment_reply` | free with `text`, 1 when nooticr writes it | Stores a reply to one comment; nothing is posted. Returns a `draftId` and a `confirmationToken` to show the user first. |
134
+ | `post_comment_reply` | free | Posts a stored draft as your own account. Needs the `draftId`, `confirm: true` and the `confirmationToken`, and only after the user has agreed to the exact text. Each comment is answered at most once. |
126
135
  | `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
127
136
  | `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
128
137
  | `show_trend` | free | Draws the trend your model read out of `mention_trend`. `tooShort` marks a series with too few points to call a direction, and `edgeIsRecordStart` marks a left edge that is where the record begins rather than where the conversation did — a chart hiding either is the mistake this view could otherwise make on your behalf. Makes no requests. |
@@ -188,7 +197,7 @@ a `scanId` for the rest.
188
197
  | `get_tool_run` | free | One run from that history by id, with its full error. |
189
198
  | `get_usage_report` | free | The run ledger added up over a window: total calls, failures and credits actually taken, per tool, per day and the most recent failures. `scope: "workspace"` adds one row per seat, for the workspace's owners and admins. |
190
199
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
191
- | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. "Manage comments" reports the platform's grant; no nooticr tool posts or replies to a comment. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
200
+ | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. "Manage comments" reports the platform's grant; this tool posts nothing; a reply goes out only through `post_comment_reply`, on a draft the user confirmed. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
192
201
  | `connect_social_account` | free | A link the user opens to connect one account. They approve at the provider in their own browser; nothing is connected until they do, and no credential passes through the tool or the model's context. Each call mints a fresh link — an old one does not work twice. |
193
202
  | `hire_employee` | free to call | Hire an AI employee (for example a Social Media Manager) that works on a schedule inside your workspace. Two calls by design: the first creates nothing and returns a quote and a `confirmationToken`; call again with `confirm: true` and that token only after you say yes. Public actions wait for your approval unless autopilot is on. Its runs bill the hiring user, up to the monthly budget. |
194
203
  | `list_employees` | free | Your AI employees with status, schedule, next run and budget left; `includeFired: true` adds the ones let go. |
@@ -45,8 +45,9 @@ export function registerConnectionTools(server, makeClient) {
45
45
  "no, or unknown; unknown means the grant predates scope recording, so treat it as \"try " +
46
46
  "it\", not as a refusal. Also returns which platforms can be connected at all, which is a " +
47
47
  "smaller set than the networks nooticr can read. \"Manage comments\" reports what the " +
48
- "platform granted, not something nooticr does: no nooticr tool posts, replies to or " +
49
- "moderates a comment, so a yes there never means a drafted reply will be sent. Call this " +
48
+ "platform granted, not something this tool does: this tool posts nothing, and a yes there " +
49
+ "never means a drafted reply will be sent by itself. A reply goes out only through " +
50
+ "post_comment_reply, on a draft the user confirmed. Call this " +
50
51
  "before promising that something can be posted or replied to. No cost to call.",
51
52
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
52
53
  inputSchema: z.object({}).strict(),
@@ -46,7 +46,7 @@ export function registerEmployeeTools(server, makeClient) {
46
46
  title: "Hire Employee",
47
47
  description: "Hire an AI employee (for example a Social Media Manager) that works on a schedule inside " +
48
48
  "this workspace. Pass `template` and optionally name, instructions, products (the string all, or a list of product ids; appId is deprecated), schedule, " +
49
- "monthlyBudgetCredits, maxCreditsPerRun, approvalPolicy and notify. " +
49
+ "monthlyBudgetCredits, maxCreditsPerRun, approvalPolicy and notify (channels, and recipients: hirer, admins or member user ids). " +
50
50
  TWO_STEP_RULE +
51
51
  " Public actions (publish, email, reply, listing or price changes) wait for the user's " +
52
52
  "approval unless autopilot is turned on. The hiring user is billed for runs, up to the " +
@@ -69,7 +69,7 @@ export function registerEmployeeTools(server, makeClient) {
69
69
  monthlyBudgetCredits: z.number().int().min(1).optional(),
70
70
  maxCreditsPerRun: z.number().int().min(1).optional(),
71
71
  approvalPolicy: z.object({}).passthrough().optional().describe("Action type -> 'approval' | 'auto'."),
72
- notify: z.object({}).passthrough().optional().describe("{channels:['email'|'slack']}."),
72
+ notify: z.object({}).passthrough().optional().describe("{channels:['email'|'slack'], recipients?:'hirer'|'admins'|[member user ids, max 25]}."),
73
73
  model: z.string().optional().describe("Optional model."),
74
74
  effort: z.string().optional().describe("low | medium | high."),
75
75
  confirm,
@@ -799,8 +799,8 @@ export function registerJobTools(server, makeClient, store) {
799
799
  postsChecked: threads.length,
800
800
  // Stated in the payload as well as the prose, because a model that
801
801
  // believes it can send will promise the user something that cannot
802
- // happen. Some grants do allow managing comments (#29); nooticr has no
803
- // send path whatever the grant says.
802
+ // happen. This payload comes from the drafting tools, which never post;
803
+ // only post_comment_reply sends, and only a confirmed draft.
804
804
  repliesCanBeSent: false,
805
805
  totalMentions: totalComments,
806
806
  totalThreads: threads.length,
@@ -487,6 +487,13 @@ export const LOADING_PLANS = {
487
487
  // Paid, but the first call only quotes; the charge waits for the confirmed second call.
488
488
  creative_image: { label: "Quoting the images", kind: "text", n: 1, steps: [] },
489
489
  creative_image_edit: { label: "Quoting the image edit", kind: "text", n: 1, steps: [] },
490
+ // Google Sheets (sheets.ts): reads are free; writes are free too (approval waits on the server side).
491
+ sheets_create: { label: "Creating the spreadsheet", kind: "text", n: 1, steps: [], free: true },
492
+ sheets_list: { label: "Listing your spreadsheets", kind: "list", n: 3, steps: [], free: true },
493
+ sheets_read: { label: "Reading the spreadsheet", kind: "text", n: 1, steps: [], free: true },
494
+ sheets_append_rows: { label: "Adding rows to the spreadsheet", kind: "text", n: 1, steps: [], free: true },
495
+ sheets_update_range: { label: "Updating the spreadsheet", kind: "text", n: 1, steps: [], free: true },
496
+ sheets_add_sheet: { label: "Adding a tab to the spreadsheet", kind: "text", n: 1, steps: [], free: true },
490
497
  list_brand_watches: { label: "Reading your watches", kind: "list", n: 3, steps: [], free: true },
491
498
  stop_brand_watch: { label: "Stopping the watch", kind: "text", n: 1, steps: [], free: true },
492
499
  update_watch_portfolio: { label: "Updating the portfolio", kind: "text", n: 1, steps: [], free: true },
@@ -530,6 +537,15 @@ export const LOADING_PLANS = {
530
537
  list_watchlist: { label: "Reading your watchlist", kind: "text", n: 1, steps: [], free: true },
531
538
  list_social_connections: { label: "Reading your connections", kind: "list", n: 3, steps: [], free: true },
532
539
  connect_social_account: { label: "Minting a fresh link", kind: "text", n: 1, steps: [], free: true },
540
+ list_comments_to_answer: { label: "Reading the comments waiting for a reply", kind: "list", n: 5, steps: [], free: true },
541
+ // Free when the caller passes `text`; 1 credit when the server writes the reply.
542
+ draft_comment_reply: {
543
+ label: "Drafting the reply",
544
+ kind: "text",
545
+ n: 1,
546
+ steps: [{ via: "draft_comment_reply", label: "Writing the reply", detail: "Free when you pass the text yourself", credits: 1 }],
547
+ },
548
+ post_comment_reply: { label: "Posting the reply", kind: "text", n: 1, steps: [], free: true },
533
549
  create_product: { label: "Writing the row", kind: "text", n: 1, steps: [], free: true },
534
550
  update_product: { label: "Patching the row", kind: "text", n: 1, steps: [], free: true },
535
551
  };
@@ -640,8 +640,9 @@ export const OUTPUT_SCHEMAS = {
640
640
  since: scalar().describe("The date window asked for, if any."),
641
641
  sinceApplied: scalar().describe("False when the platform returned no dates, so the window could not be honoured."),
642
642
  postsChecked: scalar().describe("Posts whose comments were actually read."),
643
- repliesCanBeSent: scalar().describe("Always false. This tool only drafts and never sends; a reply is posted only by a separate step, " +
644
- "on the user's connected account, after the user confirms the exact text."),
643
+ repliesCanBeSent: scalar().describe("Always false: answer_my_audience posts nothing, so the drafts this produces are for a person to " +
644
+ "paste in themselves, or to send through draft_comment_reply and post_comment_reply after " +
645
+ "confirming the text."),
645
646
  totalMentions: scalar().describe("Comments returned across every group."),
646
647
  totalThreads: scalar(),
647
648
  wantsReplyCount: scalar().describe("How many carry a reply signal. A sort, not a verdict."),
@@ -1327,6 +1328,13 @@ export const OUTPUT_SCHEMAS = {
1327
1328
  pollAfterSeconds: scalar(),
1328
1329
  error: scalar(),
1329
1330
  }),
1331
+ // Google Sheets (nooticr-server BK2, sheets.ts): the server's own shapes, only stable keys named.
1332
+ sheets_create: open({ id: scalar(), url: scalar(), title: scalar(), sheets: listOf(oneType(z.string())), owner: scalar() }),
1333
+ sheets_list: open({ files: listOf(open({ id: scalar(), title: scalar(), url: scalar() })) }),
1334
+ sheets_read: open({}),
1335
+ sheets_append_rows: open({ id: scalar(), sheet: scalar(), rowsAppended: scalar(), cells: scalar(), updatedRange: scalar() }),
1336
+ sheets_update_range: open({ id: scalar(), updatedRange: scalar(), updatedCells: scalar(), cells: scalar() }),
1337
+ sheets_add_sheet: open({ id: scalar(), sheetId: scalar(), name: scalar() }),
1330
1338
  list_brand_watches: open({
1331
1339
  watches: listOf(open({
1332
1340
  watchId: scalar(),
@@ -1689,6 +1697,39 @@ export const OUTPUT_SCHEMAS = {
1689
1697
  connections: listOf(anyObject()),
1690
1698
  message: scalar(),
1691
1699
  }),
1700
+ list_comments_to_answer: open({
1701
+ account: open({
1702
+ platform: scalar(),
1703
+ username: scalar(),
1704
+ influencerId: scalar(),
1705
+ canManageComments: scalar(),
1706
+ manageCommentsStatus: scalar(),
1707
+ }).nullish(),
1708
+ comments: listOf(open({})),
1709
+ unsupported: listOf(open({ platform: scalar(), reason: scalar() })),
1710
+ note: scalar(),
1711
+ message: scalar(),
1712
+ }),
1713
+ draft_comment_reply: open({
1714
+ draftId: scalar(),
1715
+ reply: scalar().describe("The stored reply text. Show it to the user before posting."),
1716
+ comment: open({ author: scalar(), snippet: scalar() }).nullish(),
1717
+ postUrl: scalar(),
1718
+ status: scalar(),
1719
+ expiresInSeconds: scalar(),
1720
+ confirmationToken: scalar().describe("Pass to post_comment_reply with confirm: true after the user agrees."),
1721
+ rejected: scalar(),
1722
+ instructions: scalar(),
1723
+ mcpCredits: open({ cost: scalar(), balance: scalar() }).nullish(),
1724
+ }),
1725
+ post_comment_reply: open({
1726
+ posted: scalar(),
1727
+ alreadyPosted: scalar(),
1728
+ draftId: scalar(),
1729
+ platformReplyId: scalar(),
1730
+ url: scalar(),
1731
+ postedAt: scalar(),
1732
+ }),
1692
1733
  // Marketplace. One shape for all three collection tools: a status poll
1693
1734
  // returns exactly what the scan that started it returns, and a single
1694
1735
  // product is a scan of one — describing them differently would be three
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Replying to comments on the user's own posts (nooticr-server R1).
3
+ *
4
+ * Three tools, in the order a reply travels: list what is waiting, draft one
5
+ * reply (stored, nothing sent), post it. The first two never write to a
6
+ * network. `post_comment_reply` does, as the user's own account, so it needs
7
+ * the exact draft the user saw: the draftId, `confirm: true` and the
8
+ * confirmationToken that `draft_comment_reply` returned. The server enforces
9
+ * that; the schema here requires all three so a model cannot omit one.
10
+ *
11
+ * Thin passthroughs, like connections.ts: the server owns the rules.
12
+ */
13
+ import { z } from "zod";
14
+ import { OUTPUT_SCHEMAS } from "./output-schemas.js";
15
+ function toResult(proxy) {
16
+ const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
17
+ return {
18
+ content: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
19
+ structuredContent: proxy.structured,
20
+ };
21
+ }
22
+ function failed(prefix, err) {
23
+ const msg = err instanceof Error ? err.message : String(err);
24
+ return {
25
+ content: [{ type: "text", text: `${prefix}: ${msg}` }],
26
+ isError: true,
27
+ };
28
+ }
29
+ const platform = z.enum(["youtube", "instagram"]);
30
+ export function registerReplyTools(server, makeClient) {
31
+ server.registerTool("list_comments_to_answer", {
32
+ title: "List Comments To Answer",
33
+ description: "List recent comments on the user's own connected account that nobody has answered yet, " +
34
+ "newest first. Only the user's own posts, through their own connection: YouTube works once " +
35
+ "connected with capabilities: [\"manage_comments\"] (see connect_social_account); networks " +
36
+ "that cannot be answered come back under `unsupported` with the reason. Optional platform, " +
37
+ "influencerId, postId, sinceDays (1-30, default 7), limit (1-100, default 30), " +
38
+ "includeAnswered. Read-only: it sends nothing. No cost to call.",
39
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
40
+ outputSchema: OUTPUT_SCHEMAS.list_comments_to_answer,
41
+ inputSchema: z
42
+ .object({
43
+ platform: platform.optional().describe("Only this network."),
44
+ influencerId: z.number().int().optional().describe("Creator profile id when more than one account is connected."),
45
+ postId: z.string().optional().describe("Only comments on this video/media id."),
46
+ sinceDays: z.number().int().min(1).max(30).optional().describe("How far back to look (default 7)."),
47
+ limit: z.number().int().min(1).max(100).optional().describe("Most comments to return (default 30)."),
48
+ includeAnswered: z.boolean().optional().describe("Also list comments that already have a reply."),
49
+ })
50
+ .strict(),
51
+ }, async (args, extra) => {
52
+ const client = await makeClient({ ...extra, arguments: args });
53
+ try {
54
+ return toResult(await client.callTool("list_comments_to_answer", args));
55
+ }
56
+ catch (err) {
57
+ return failed("list_comments_to_answer failed", err);
58
+ }
59
+ });
60
+ server.registerTool("draft_comment_reply", {
61
+ title: "Draft Comment Reply",
62
+ description: "Draft a reply to one comment from list_comments_to_answer and store it; nothing is " +
63
+ "posted. Pass `text` to use the user's own wording (no cost), or omit it and nooticr " +
64
+ "writes one (costs 1 nooticr credit); `guidance` steers the writing. Replies may not contain links, " +
65
+ "emails, phone numbers or @-mentions of anyone but the commenter. Returns a draftId and a " +
66
+ "confirmationToken: show the reply to the user first, then post it with post_comment_reply.",
67
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
68
+ outputSchema: OUTPUT_SCHEMAS.draft_comment_reply,
69
+ inputSchema: z
70
+ .object({
71
+ platform: platform.describe("The network the comment is on."),
72
+ commentId: z.string().describe("Comment id from list_comments_to_answer."),
73
+ postId: z.string().describe("Post id the comment is on."),
74
+ text: z.string().optional().describe("The reply to store, in the user's words. Omit to have one written (1 credit)."),
75
+ influencerId: z.number().int().optional().describe("Creator profile id when more than one account is connected."),
76
+ guidance: z.string().optional().describe("What the reply should say or avoid; a URL here is the only URL the reply may contain."),
77
+ })
78
+ .strict(),
79
+ }, async (args, extra) => {
80
+ const client = await makeClient({ ...extra, arguments: args });
81
+ try {
82
+ return toResult(await client.callTool("draft_comment_reply", args));
83
+ }
84
+ catch (err) {
85
+ return failed("draft_comment_reply failed", err);
86
+ }
87
+ });
88
+ server.registerTool("post_comment_reply", {
89
+ title: "Post Comment Reply",
90
+ description: "Post a stored reply draft to the comment, as the user's own account. Only after the user " +
91
+ "has seen and agreed to the exact text: pass the draftId, confirm: true and the " +
92
+ "confirmationToken from draft_comment_reply. Each comment is answered at most once and " +
93
+ "daily limits apply. It writes to a third-party network and cannot be undone from here. " +
94
+ "No cost to call.",
95
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
96
+ outputSchema: OUTPUT_SCHEMAS.post_comment_reply,
97
+ inputSchema: z
98
+ .object({
99
+ draftId: z.string().describe("Draft id from draft_comment_reply."),
100
+ confirm: z.boolean().describe("true only once the user has agreed to the exact text."),
101
+ confirmationToken: z.string().describe("Token returned with the draft."),
102
+ })
103
+ .strict(),
104
+ }, async (args, extra) => {
105
+ const client = await makeClient({ ...extra, arguments: args });
106
+ try {
107
+ return toResult(await client.callTool("post_comment_reply", args));
108
+ }
109
+ catch (err) {
110
+ return failed("post_comment_reply failed", err);
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * nooticr-server's `sheets_*` family (BK2, Google Sheets): create, list, read
3
+ * and write spreadsheets in the customer's connected Google account. Plain
4
+ * passthroughs to the server's `POST /mcp` dispatch, which owns the Google
5
+ * connection, the approval rule for files the customer picked, and the
6
+ * 10000-cell limit. Argument names match the server's schema exactly.
7
+ */
8
+ import { z } from "zod";
9
+ import { OUTPUT_SCHEMAS } from "./output-schemas.js";
10
+ function toResult(proxy) {
11
+ const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
12
+ const structured = proxy.structured;
13
+ const text = structured ? JSON.stringify(structured) : String(textBlock?.text ?? "");
14
+ return { content: [{ type: "text", text }], structuredContent: structured };
15
+ }
16
+ function failed(prefix, err) {
17
+ const msg = err instanceof Error ? err.message : String(err);
18
+ return {
19
+ content: [{ type: "text", text: `${prefix}: ${msg}` }],
20
+ isError: true,
21
+ };
22
+ }
23
+ const appId = z.number().int().optional().describe("Product id; optional when the workspace has one product.");
24
+ const spreadsheetId = z.string().describe("Spreadsheet id from sheets_create or sheets_list.");
25
+ const cell = z
26
+ .union([z.number(), z.boolean(), z.string(), z.null(), z.object({ formula: z.string() }).strict()])
27
+ .describe("A number, boolean, text or an ISO date (2026-01-31) or datetime; null leaves the cell empty. " +
28
+ 'Text that looks like a formula or number stays text; use {"formula": "=SUM(A1:A3)"} for a formula.');
29
+ const rows = z.array(z.array(cell));
30
+ const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
31
+ const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
32
+ export function registerSheetsTools(server, makeClient) {
33
+ const passthrough = (name) => async (args, extra) => {
34
+ const client = await makeClient({ ...extra, arguments: args });
35
+ try {
36
+ return toResult(await client.callTool(name, args));
37
+ }
38
+ catch (err) {
39
+ return failed(`${name} failed`, err);
40
+ }
41
+ };
42
+ server.registerTool("sheets_create", {
43
+ title: "Create Spreadsheet",
44
+ description: "Create a Google spreadsheet in the connected Google account, with the named sheets and a header row on each. " +
45
+ "Returns its id and URL; Nooticr owns the file, so later writes need no approval. " +
46
+ "Requires the product's Google Sheets connection. Free.",
47
+ annotations: WRITE,
48
+ inputSchema: z
49
+ .object({
50
+ appId,
51
+ title: z.string(),
52
+ sheets: z
53
+ .array(z.object({ name: z.string(), headers: z.array(z.string()).optional() }))
54
+ .optional()
55
+ .describe("Tabs to create."),
56
+ })
57
+ .strict(),
58
+ outputSchema: OUTPUT_SCHEMAS.sheets_create,
59
+ }, passthrough("sheets_create"));
60
+ server.registerTool("sheets_list", {
61
+ title: "List Spreadsheets",
62
+ description: "List the spreadsheets this connection can see: the ones Nooticr created and the ones the customer picked. " +
63
+ "Each says whether it was created or picked and whether the employee may edit it without approval. Free.",
64
+ annotations: READ,
65
+ inputSchema: z.object({ appId, limit: z.number().int().min(1).max(100).optional() }).strict(),
66
+ outputSchema: OUTPUT_SCHEMAS.sheets_list,
67
+ }, passthrough("sheets_list"));
68
+ server.registerTool("sheets_read", {
69
+ title: "Read Spreadsheet Range",
70
+ description: 'Read a range (A1 notation, e.g. "Ledger!A1:F200") of a spreadsheet. ' +
71
+ "Numbers come back as numbers and dates as formatted text; at most 10000 cells, " +
72
+ 'and a range open at the bottom ("A:F") or longer than the cap is cut to the rows that fit, ' +
73
+ "with truncated=true when more may remain: read further down with a range that starts after the last row. Free.",
74
+ annotations: READ,
75
+ inputSchema: z.object({ appId, id: spreadsheetId, range: z.string() }).strict(),
76
+ outputSchema: OUTPUT_SCHEMAS.sheets_read,
77
+ }, passthrough("sheets_read"));
78
+ server.registerTool("sheets_append_rows", {
79
+ title: "Append Spreadsheet Rows",
80
+ description: "Append rows after the last row of a sheet (tab). Values are typed: numbers stay numbers and ISO dates become sheet dates. " +
81
+ "At most 10000 cells per call. Free on a file Nooticr created or one the customer allowed you to edit; " +
82
+ "on any other file the customer picked it waits for their approval.",
83
+ annotations: WRITE,
84
+ inputSchema: z.object({ appId, id: spreadsheetId, sheet: z.string().describe("Tab name."), rows }).strict(),
85
+ outputSchema: OUTPUT_SCHEMAS.sheets_append_rows,
86
+ }, passthrough("sheets_append_rows"));
87
+ server.registerTool("sheets_update_range", {
88
+ title: "Update Spreadsheet Range",
89
+ description: 'Overwrite a range (A1 notation, e.g. "Ledger!B2:C3") with values. Typed like sheets_append_rows; ' +
90
+ "at most 10000 cells per call. Same approval rule as sheets_append_rows: free on a file Nooticr created or one the customer allowed you to edit, otherwise it waits for their approval.",
91
+ annotations: WRITE,
92
+ inputSchema: z.object({ appId, id: spreadsheetId, range: z.string(), values: rows }).strict(),
93
+ outputSchema: OUTPUT_SCHEMAS.sheets_update_range,
94
+ }, passthrough("sheets_update_range"));
95
+ server.registerTool("sheets_add_sheet", {
96
+ title: "Add Spreadsheet Tab",
97
+ description: "Add an empty sheet (tab) to a spreadsheet. Same approval rule as sheets_append_rows: free on a file Nooticr created or one the customer allowed you to edit, otherwise it waits for their approval.",
98
+ annotations: WRITE,
99
+ inputSchema: z.object({ appId, id: spreadsheetId, name: z.string() }).strict(),
100
+ outputSchema: OUTPUT_SCHEMAS.sheets_add_sheet,
101
+ }, passthrough("sheets_add_sheet"));
102
+ }
@@ -312,6 +312,42 @@ export const TOOL_DEFINITIONS = [
312
312
  description: "Status of an asynchronous creative job by jobId: queued, running, done, failed or canceled, with the finished assets and the credits quoted, charged and refunded. Poll after pollAfterSeconds. No cost to call.",
313
313
  inputSchema: z.object({ jobId: z.string().describe("jobId returned when the job was started.") }).strict(),
314
314
  },
315
+ {
316
+ name: "sheets_create",
317
+ title: "Create Spreadsheet",
318
+ description: "Create a Google spreadsheet in the connected Google account, with the named sheets and a header row on each. Returns its id and URL; Nooticr owns the file, so later writes need no approval. Requires the product's Google Sheets connection. Free.",
319
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), title: z.string(), sheets: z.array(z.object({ name: z.string(), headers: z.array(z.string()).optional() })).optional().describe("Tabs to create.") }).strict(),
320
+ },
321
+ {
322
+ name: "sheets_list",
323
+ title: "List Spreadsheets",
324
+ description: "List the spreadsheets this connection can see: the ones Nooticr created and the ones the customer picked. Each says whether it was created or picked and whether the employee may edit it without approval. Free.",
325
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), limit: z.number().int().min(1).max(100).optional() }).strict(),
326
+ },
327
+ {
328
+ name: "sheets_read",
329
+ title: "Read Spreadsheet Range",
330
+ description: "Read a range (A1 notation, e.g. \"Ledger!A1:F200\") of a spreadsheet. Numbers come back as numbers and dates as formatted text; at most 10000 cells, and a range open at the bottom (\"A:F\") or longer than the cap is cut to the rows that fit, with truncated=true when more may remain: read further down with a range that starts after the last row. Free.",
331
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), id: z.string().describe("Spreadsheet id from sheets_create or sheets_list."), range: z.string() }).strict(),
332
+ },
333
+ {
334
+ name: "sheets_append_rows",
335
+ title: "Append Spreadsheet Rows",
336
+ description: "Append rows after the last row of a sheet (tab). Values are typed: numbers stay numbers and ISO dates become sheet dates. At most 10000 cells per call. Free on a file Nooticr created or one the customer allowed you to edit; on any other file the customer picked it waits for their approval.",
337
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), id: z.string().describe("Spreadsheet id from sheets_create or sheets_list."), sheet: z.string().describe("Tab name."), rows: z.array(z.array(z.union([z.number(), z.boolean(), z.string(), z.null(), z.object({ formula: z.string() }).strict()]).describe('A number, boolean, text or an ISO date (2026-01-31) or datetime; null leaves the cell empty. Text that looks like a formula or number stays text; use {"formula": "=SUM(A1:A3)"} for a formula.'))) }).strict(),
338
+ },
339
+ {
340
+ name: "sheets_update_range",
341
+ title: "Update Spreadsheet Range",
342
+ description: "Overwrite a range (A1 notation, e.g. \"Ledger!B2:C3\") with values. Typed like sheets_append_rows; at most 10000 cells per call. Same approval rule as sheets_append_rows: free on a file Nooticr created or one the customer allowed you to edit, otherwise it waits for their approval.",
343
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), id: z.string().describe("Spreadsheet id from sheets_create or sheets_list."), range: z.string(), values: z.array(z.array(z.union([z.number(), z.boolean(), z.string(), z.null(), z.object({ formula: z.string() }).strict()]).describe('A number, boolean, text or an ISO date (2026-01-31) or datetime; null leaves the cell empty. Text that looks like a formula or number stays text; use {"formula": "=SUM(A1:A3)"} for a formula.'))) }).strict(),
344
+ },
345
+ {
346
+ name: "sheets_add_sheet",
347
+ title: "Add Spreadsheet Tab",
348
+ description: "Add an empty sheet (tab) to a spreadsheet. Same approval rule as sheets_append_rows: free on a file Nooticr created or one the customer allowed you to edit, otherwise it waits for their approval.",
349
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Product id; optional when the workspace has one product."), id: z.string().describe("Spreadsheet id from sheets_create or sheets_list."), name: z.string() }).strict(),
350
+ },
315
351
  {
316
352
  name: "list_brand_watches",
317
353
  title: "List Brand Watches",
@@ -441,7 +477,7 @@ export const TOOL_DEFINITIONS = [
441
477
  {
442
478
  name: "list_social_connections",
443
479
  title: "List Social Connections",
444
- description: "List the social accounts your workspace has connected and what each connection is allowed to do — read the account, publish a post, manage comments. Manage comments reports the grant; no nooticr tool posts or replies to a comment. Also returns which platforms can be connected at all. No cost to call.",
480
+ description: "List the social accounts your workspace has connected and what each connection is allowed to do — read the account, publish a post, manage comments. Manage comments reports the grant; this tool posts nothing, and a reply goes out only through post_comment_reply on a draft the user confirmed. Also returns which platforms can be connected at all. No cost to call.",
445
481
  inputSchema: z.object({}).strict(),
446
482
  },
447
483
  {
@@ -450,6 +486,24 @@ export const TOOL_DEFINITIONS = [
450
486
  description: "Get a link to open so you can connect one social account. The user approves at the provider; nothing is connected until they do, and no credential ever passes through this tool. For YouTube, pass capabilities [\"manage_comments\"] to also ask for the comment-management grant. No cost to call.",
451
487
  inputSchema: z.object({ platform: z.string().describe("Which network to connect."), influencerId: z.number().int().optional(), appId: z.number().int().optional(), capabilities: z.array(z.enum(["manage_comments"])).optional().describe("YouTube only: [\"manage_comments\"] asks for the comment-management grant.") }).strict(),
452
488
  },
489
+ {
490
+ name: "list_comments_to_answer",
491
+ title: "List Comments To Answer",
492
+ description: "List recent comments on the user's own connected account that nobody has answered yet, newest first. Only the user's own posts, through their own connection: YouTube works once connected with capabilities: [\"manage_comments\"] (see connect_social_account); networks that cannot be answered come back under `unsupported` with the reason. Optional platform, influencerId, postId, sinceDays (1-30, default 7), limit (1-100, default 30), includeAnswered. Read-only: it sends nothing. No cost to call.",
493
+ inputSchema: z.object({ platform: z.enum(["youtube", "instagram"]).optional().describe("Only this network."), influencerId: z.number().int().optional().describe("Creator profile id when more than one account is connected."), postId: z.string().optional().describe("Only comments on this video/media id."), sinceDays: z.number().int().min(1).max(30).optional().describe("How far back to look (default 7)."), limit: z.number().int().min(1).max(100).optional().describe("Most comments to return (default 30)."), includeAnswered: z.boolean().optional().describe("Also list comments that already have a reply.") }).strict(),
494
+ },
495
+ {
496
+ name: "draft_comment_reply",
497
+ title: "Draft Comment Reply",
498
+ description: "Draft a reply to one comment from list_comments_to_answer and store it; nothing is posted. Pass `text` to use the user's own wording (no cost), or omit it and nooticr writes one (costs 1 nooticr credit); `guidance` steers the writing. Replies may not contain links, emails, phone numbers or @-mentions of anyone but the commenter. Returns a draftId and a confirmationToken: show the reply to the user first, then post it with post_comment_reply.",
499
+ inputSchema: z.object({ platform: z.enum(["youtube", "instagram"]).describe("The network the comment is on."), commentId: z.string().describe("Comment id from list_comments_to_answer."), postId: z.string().describe("Post id the comment is on."), text: z.string().optional().describe("The reply to store, in the user's words. Omit to have one written (1 credit)."), influencerId: z.number().int().optional().describe("Creator profile id when more than one account is connected."), guidance: z.string().optional().describe("What the reply should say or avoid; a URL here is the only URL the reply may contain.") }).strict(),
500
+ },
501
+ {
502
+ name: "post_comment_reply",
503
+ title: "Post Comment Reply",
504
+ description: "Post a stored reply draft to the comment, as the user's own account. Only after the user has seen and agreed to the exact text: pass the draftId, confirm: true and the confirmationToken from draft_comment_reply. Each comment is answered at most once and daily limits apply. It writes to a third-party network and cannot be undone from here. No cost to call.",
505
+ inputSchema: z.object({ draftId: z.string().describe("Draft id from draft_comment_reply."), confirm: z.boolean().describe("true only once the user has agreed to the exact text."), confirmationToken: z.string().describe("Token returned with the draft.") }).strict(),
506
+ },
453
507
  {
454
508
  name: "connect_store",
455
509
  title: "Connect Store",
@@ -465,7 +519,7 @@ export const TOOL_DEFINITIONS = [
465
519
  {
466
520
  name: "answer_my_audience",
467
521
  title: "Answer My Audience",
468
- description: "The questions waiting for you under your own posts. Fetches a creator's recent posts, reads the comments on each, and returns them grouped under the post they were left on \u2014 every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. Finds and drafts; this tool cannot post a reply \u2014 a reply is posted only after the user confirms its exact text. Use when the job is to answer your own audience rather than to read about strangers. Consumes 2 nooticr credits for the post list plus 2 per post opened \u2014 14 credits at the default of 6 posts.",
522
+ description: "The questions waiting for you under your own posts. Fetches a creator's recent posts, reads the comments on each, and returns them grouped under the post they were left on \u2014 every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. Finds and drafts; it cannot post a reply \u2014 the drafts are for a person to paste in themselves, or to send through draft_comment_reply and post_comment_reply once the user has confirmed the text. Use when the job is to answer your own audience rather than to read about strangers. Consumes 2 nooticr credits for the post list plus 2 per post opened \u2014 14 credits at the default of 6 posts.",
469
523
  inputSchema: z.object({ username: z.string().describe("Your handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts to open (default 6, max 12). Each one is a comment fetch, so this is the price."), commentsPerPost: z.number().int().optional().describe("Comments to read per post (default 20, max 50)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD. Windows the posts, not the comments.") }).strict(),
470
524
  },
471
525
  {
@@ -573,8 +627,8 @@ export const TOOL_DEFINITIONS = [
573
627
  {
574
628
  name: "hire_employee",
575
629
  title: "Hire Employee",
576
- description: "Hire an AI employee (for example a Social Media Manager) that works on a schedule inside this workspace. Pass `template` and optionally name, instructions, products (the string all, or a list of product ids; appId is deprecated), schedule, monthlyBudgetCredits, maxCreditsPerRun, approvalPolicy and notify. The first call changes nothing and returns a preview and a confirmationToken; show it to the user, and call again with confirm: true and the token only after the user explicitly agrees — never confirm on the user's behalf. Public actions (publish, email, reply, listing or price changes) wait for the user's approval unless autopilot is turned on. The hiring user is billed for runs, up to the monthly budget. No cost to call.",
577
- inputSchema: z.object({ template: z.string().describe("Template slug, e.g. social-media-manager."), name: z.string().optional().describe("What to call the employee."), instructions: z.string().optional().describe("Standing instructions from the customer (tone, topics to avoid, ...)."), appId: z.number().int().optional().describe("Deprecated: same as products:[appId]. Do not send together with products."), products: z.union([z.literal("all"), z.array(z.number().int()).min(1)]).optional().describe('"all" (every product, including ones added later) or a list of product ids the employee covers.'), schedule: z.object({}).passthrough().optional().describe("{kind:'weekly',days:['mon'],times:['09:00'],tz:'Europe/Paris'} | {kind:'interval',minutes:N>=60} | {kind:'manual'}."), monthlyBudgetCredits: z.number().int().min(1).optional(), maxCreditsPerRun: z.number().int().min(1).optional(), approvalPolicy: z.object({}).passthrough().optional().describe("Action type -> 'approval' | 'auto'."), notify: z.object({}).passthrough().optional().describe("{channels:['email'|'slack']}."), model: z.string().optional().describe("Optional model."), effort: z.string().optional().describe("low | medium | high."), confirm: z.boolean().optional(), confirmationToken: z.string().optional().describe("Token returned in the quote.") }).strict(),
630
+ description: "Hire an AI employee (for example a Social Media Manager) that works on a schedule inside this workspace. Pass `template` and optionally name, instructions, products (the string all, or a list of product ids; appId is deprecated), schedule, monthlyBudgetCredits, maxCreditsPerRun, approvalPolicy and notify (channels, and recipients: hirer, admins or member user ids). The first call changes nothing and returns a preview and a confirmationToken; show it to the user, and call again with confirm: true and the token only after the user explicitly agrees — never confirm on the user's behalf. Public actions (publish, email, reply, listing or price changes) wait for the user's approval unless autopilot is turned on. The hiring user is billed for runs, up to the monthly budget. No cost to call.",
631
+ inputSchema: z.object({ template: z.string().describe("Template slug, e.g. social-media-manager."), name: z.string().optional().describe("What to call the employee."), instructions: z.string().optional().describe("Standing instructions from the customer (tone, topics to avoid, ...)."), appId: z.number().int().optional().describe("Deprecated: same as products:[appId]. Do not send together with products."), products: z.union([z.literal("all"), z.array(z.number().int()).min(1)]).optional().describe('"all" (every product, including ones added later) or a list of product ids the employee covers.'), schedule: z.object({}).passthrough().optional().describe("{kind:'weekly',days:['mon'],times:['09:00'],tz:'Europe/Paris'} | {kind:'interval',minutes:N>=60} | {kind:'manual'}."), monthlyBudgetCredits: z.number().int().min(1).optional(), maxCreditsPerRun: z.number().int().min(1).optional(), approvalPolicy: z.object({}).passthrough().optional().describe("Action type -> 'approval' | 'auto'."), notify: z.object({}).passthrough().optional().describe("{channels:['email'|'slack'], recipients?:'hirer'|'admins'|[member user ids, max 25]}."), model: z.string().optional().describe("Optional model."), effort: z.string().optional().describe("low | medium | high."), confirm: z.boolean().optional(), confirmationToken: z.string().optional().describe("Token returned in the quote.") }).strict(),
578
632
  },
579
633
  {
580
634
  name: "list_employees",
@@ -22,8 +22,10 @@ import { BackendWatchStore, MemoryWatchStore, registerWatchlist, } from "./watch
22
22
  import { registerJobTools } from "./jobs.js";
23
23
  import { registerBrandWatch } from "./brand-watch.js";
24
24
  import { registerCreativeTools } from "./creative.js";
25
+ import { registerSheetsTools } from "./sheets.js";
25
26
  import { registerOwnAccountTools } from "./own-account.js";
26
27
  import { registerConnectionTools } from "./connections.js";
28
+ import { registerReplyTools } from "./replies.js";
27
29
  import { registerEmployeeTools } from "./employees.js";
28
30
  import { registerStoreReadTools } from "./store-read.js";
29
31
  import { registerGettingStarted, SERVER_INSTRUCTIONS } from "./getting-started.js";
@@ -34,7 +36,7 @@ import { registerAmazonTools } from "./amazon.js";
34
36
  import { registerMarketplaceTools } from "./marketplace.js";
35
37
  import { DEFAULT_NOOTICR_SETTINGS, DEFAULT_SOCIAL_PLATFORMS, MemorySettingsStore, SETTINGS_READ_TOOL, SETTINGS_UPDATE_TOOL, settingsOwnerFromAuth, } from "./settings.js";
36
38
  /** Current MCP server version — bumped on every deploy for traceability. */
37
- export const MCP_SERVER_VERSION = "1.26.66";
39
+ export const MCP_SERVER_VERSION = "1.26.68";
38
40
  /** MCP Apps extension identifier */
39
41
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
40
42
  /** MIME type for MCP Apps HTML resources */
@@ -2890,7 +2892,10 @@ export function createMcpServer(rawMakeClient, opts) {
2890
2892
  registerOwnAccountTools(server, makeClient);
2891
2893
  // Creative assets (creative.ts): the free reads of nooticr-server's creative_* family.
2892
2894
  registerCreativeTools(server, makeClient);
2895
+ // Google Sheets (sheets.ts): the sheets_* family of nooticr-server BK2.
2896
+ registerSheetsTools(server, makeClient);
2893
2897
  registerConnectionTools(server, makeClient);
2898
+ registerReplyTools(server, makeClient);
2894
2899
  // AI employees (employees.ts): hire, list, pause, fire, and approve or reject what they queue.
2895
2900
  registerEmployeeTools(server, makeClient);
2896
2901
  // Store connections (store-read.ts): the connect link and the catalogue read.
@@ -4411,7 +4411,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4411
4411
  +'<div class="nt-commerce-chiprow">'+readOnly.map(function(k){
4412
4412
  return '<span class="nt-tag nt-commerce-nettag tag">'+glyph(k,12)+esc(pTitle(k))+"</span>";}).join("")+"</div>"
4413
4413
  +'<p class="nt-commerce-conn-sub" style="margin-top:8px">So "monitor my brand on Weibo" works and "reply to that Weibo comment" cannot — '
4414
- +"replies are never sent on their own: one is posted only after you confirm its exact text.</p></div>":""),
4414
+ +"replies go out only through post_comment_reply, on a draft you confirmed, and only where a connection allows managing comments.</p></div>":""),
4415
4415
  footer:'<span class="ai-note">Each press mints a fresh link — an old one does not work twice. Approve in your own browser; nothing is connected until you do.</span>'});
4416
4416
  }
4417
4417
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nooticr/mcp",
3
3
  "mcpName": "com.nooticr/mcp",
4
- "version": "1.26.66",
4
+ "version": "1.26.68",
5
5
  "description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
6
6
  "homepage": "https://mcp.nooticr.com",
7
7
  "repository": {