toolroll 0.9.36 → 0.9.37

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.
Files changed (165) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/assignment-adapters.d.ts +12 -48
  3. package/dist/assignment-adapters.js +33 -43
  4. package/dist/build-review.d.ts +1 -1
  5. package/dist/build-review.js +2 -1
  6. package/dist/chat-acceptance.d.ts +1 -1
  7. package/dist/chat-actions.d.ts +18 -12
  8. package/dist/chat-actions.js +15 -65
  9. package/dist/chat-ask.js +5 -5
  10. package/dist/chat-decide.d.ts +14 -1
  11. package/dist/chat-decide.js +12 -6
  12. package/dist/chat-delivery-state.d.ts +12 -70
  13. package/dist/chat-delivery-state.js +17 -135
  14. package/dist/chat-delivery.js +58 -30
  15. package/dist/chat-flow.js +8 -6
  16. package/dist/chat-inbox.js +1 -1
  17. package/dist/coding-context.d.ts +2 -39
  18. package/dist/coding-context.js +2 -1
  19. package/dist/coding-handoff.d.ts +6 -25
  20. package/dist/coding-handoff.js +11 -5
  21. package/dist/coding-types.d.ts +2 -26
  22. package/dist/coding-workspace.d.ts +2 -0
  23. package/dist/coding-workspace.js +50 -14
  24. package/dist/contracts/acceptance-terms.d.ts +14 -0
  25. package/dist/contracts/acceptance-terms.js +16 -0
  26. package/dist/contracts/chat-actions.d.ts +328 -0
  27. package/dist/contracts/chat-actions.js +133 -0
  28. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  29. package/dist/contracts/chat-callback-rows.js +46 -0
  30. package/dist/contracts/chat-content.d.ts +276 -0
  31. package/dist/contracts/chat-content.js +127 -0
  32. package/dist/contracts/chat-tables.d.ts +6 -0
  33. package/dist/contracts/chat-tables.js +136 -0
  34. package/dist/contracts/coding-context.d.ts +97 -0
  35. package/dist/contracts/coding-context.js +60 -0
  36. package/dist/contracts/coding-handoff.d.ts +44 -0
  37. package/dist/contracts/coding-handoff.js +47 -0
  38. package/dist/contracts/coding-workspace.d.ts +165 -0
  39. package/dist/contracts/coding-workspace.js +71 -0
  40. package/dist/contracts/contract.d.ts +7 -2
  41. package/dist/contracts/contract.js +21 -12
  42. package/dist/contracts/decision.d.ts +45 -0
  43. package/dist/contracts/decision.js +45 -0
  44. package/dist/contracts/discord-callback.d.ts +45 -0
  45. package/dist/contracts/discord-callback.js +34 -0
  46. package/dist/contracts/flow-card.d.ts +50 -0
  47. package/dist/contracts/flow-card.js +46 -0
  48. package/dist/contracts/flow-send.d.ts +119 -0
  49. package/dist/contracts/flow-send.js +112 -0
  50. package/dist/contracts/flow.d.ts +5 -0
  51. package/dist/contracts/flow.js +5 -0
  52. package/dist/contracts/gateway-tools.d.ts +374 -0
  53. package/dist/contracts/gateway-tools.js +94 -0
  54. package/dist/contracts/lead-context.d.ts +165 -0
  55. package/dist/contracts/lead-context.js +73 -0
  56. package/dist/contracts/lead-tools.d.ts +1905 -0
  57. package/dist/contracts/lead-tools.js +265 -0
  58. package/dist/contracts/memory-pass.d.ts +158 -0
  59. package/dist/contracts/memory-pass.js +102 -0
  60. package/dist/contracts/project-knowledge.d.ts +126 -0
  61. package/dist/contracts/project-knowledge.js +93 -0
  62. package/dist/contracts/project-memory.d.ts +117 -0
  63. package/dist/contracts/project-memory.js +51 -0
  64. package/dist/contracts/project-skills.d.ts +121 -0
  65. package/dist/contracts/project-skills.js +76 -0
  66. package/dist/contracts/review-findings.d.ts +46 -0
  67. package/dist/contracts/review-findings.js +48 -0
  68. package/dist/contracts/route.d.ts +484 -0
  69. package/dist/contracts/route.js +128 -0
  70. package/dist/contracts/scope.d.ts +105 -0
  71. package/dist/contracts/scope.js +114 -0
  72. package/dist/contracts/scout-report.d.ts +234 -0
  73. package/dist/contracts/scout-report.js +141 -0
  74. package/dist/contracts/slack-callback.d.ts +43 -0
  75. package/dist/contracts/slack-callback.js +35 -0
  76. package/dist/contracts/sort-answer.d.ts +83 -0
  77. package/dist/contracts/sort-answer.js +77 -0
  78. package/dist/contracts/stage-output.d.ts +127 -0
  79. package/dist/contracts/stage-output.js +166 -0
  80. package/dist/contracts/task-sizing.d.ts +24 -0
  81. package/dist/contracts/task-sizing.js +28 -0
  82. package/dist/contracts/teammate-turn.d.ts +43 -0
  83. package/dist/contracts/teammate-turn.js +51 -0
  84. package/dist/contracts/teams-callback.d.ts +14 -0
  85. package/dist/contracts/teams-callback.js +14 -0
  86. package/dist/contracts/telegram-callback.d.ts +120 -0
  87. package/dist/contracts/telegram-callback.js +97 -0
  88. package/dist/decision.d.ts +11 -20
  89. package/dist/decision.js +86 -167
  90. package/dist/desktop-update.d.ts +2 -2
  91. package/dist/discord-chat.js +31 -20
  92. package/dist/flow-engine.js +14 -17
  93. package/dist/flow-items.d.ts +4 -13
  94. package/dist/flow-items.js +1 -15
  95. package/dist/flow-send.d.ts +6 -37
  96. package/dist/flow-send.js +18 -14
  97. package/dist/flow-share.js +7 -1
  98. package/dist/flow-sort.d.ts +7 -26
  99. package/dist/flow-sort.js +25 -20
  100. package/dist/flow-steps.js +3 -2
  101. package/dist/flows.d.ts +2 -4
  102. package/dist/flows.js +9 -5
  103. package/dist/invoke.js +4 -4
  104. package/dist/lead-context.d.ts +4 -2
  105. package/dist/lead-context.js +24 -17
  106. package/dist/mate-tools.d.ts +4 -0
  107. package/dist/mate-tools.js +108 -275
  108. package/dist/mcp-connect.d.ts +24 -4
  109. package/dist/mcp-connect.js +78 -15
  110. package/dist/mcp.js +84 -216
  111. package/dist/memory-pass.d.ts +17 -42
  112. package/dist/memory-pass.js +54 -26
  113. package/dist/phase-routing.d.ts +19 -72
  114. package/dist/phase-routing.js +34 -164
  115. package/dist/project-knowledge.d.ts +16 -35
  116. package/dist/project-knowledge.js +42 -21
  117. package/dist/project-memory.d.ts +2 -28
  118. package/dist/project-memory.js +28 -13
  119. package/dist/project-skills.d.ts +16 -38
  120. package/dist/project-skills.js +41 -32
  121. package/dist/project-tools.d.ts +1 -0
  122. package/dist/project-tools.js +4 -4
  123. package/dist/provider.d.ts +2 -0
  124. package/dist/provider.js +4 -16
  125. package/dist/result-shots.d.ts +1 -1
  126. package/dist/result-shots.js +2 -1
  127. package/dist/review-switch.d.ts +3 -8
  128. package/dist/reviewer.d.ts +8 -12
  129. package/dist/reviewer.js +11 -35
  130. package/dist/scope.d.ts +21 -43
  131. package/dist/scope.js +42 -128
  132. package/dist/scout-report.d.ts +9 -194
  133. package/dist/scout-report.js +113 -264
  134. package/dist/scout.js +15 -11
  135. package/dist/serve.js +9 -2
  136. package/dist/setup-guide.d.ts +2 -2
  137. package/dist/slack-chat.js +26 -20
  138. package/dist/slack-state.js +2 -1
  139. package/dist/store.d.ts +9 -32
  140. package/dist/store.js +18 -15
  141. package/dist/structured-output.js +2 -1
  142. package/dist/subscription-chat.d.ts +4 -0
  143. package/dist/subscription-chat.js +4 -1
  144. package/dist/task-review-brief.d.ts +1 -7
  145. package/dist/task-review-brief.js +6 -6
  146. package/dist/task-sizing.d.ts +3 -27
  147. package/dist/task-sizing.js +11 -26
  148. package/dist/task-text.d.ts +0 -5
  149. package/dist/task-text.js +1 -3
  150. package/dist/teammate-question.js +7 -5
  151. package/dist/teammate-work.js +9 -5
  152. package/dist/teammates.d.ts +13 -64
  153. package/dist/teammates.js +32 -33
  154. package/dist/teams-chat.js +20 -11
  155. package/dist/telegram-flow.js +4 -3
  156. package/dist/telegram-mate.d.ts +4 -14
  157. package/dist/telegram-mate.js +8 -7
  158. package/dist/telegram-team.d.ts +2 -19
  159. package/dist/telegram.d.ts +1 -1
  160. package/dist/telegram.js +52 -42
  161. package/dist/text-limits.d.ts +142 -1
  162. package/dist/text-limits.js +144 -1
  163. package/dist/tools-ui.d.ts +2 -1
  164. package/dist/tools-ui.js +7 -4
  165. package/package.json +1 -1
@@ -14,6 +14,9 @@ export type OneClick = {
14
14
  /** Send no scope: the server applies its own preset for this resource (PostHog's MCP preset). Naming scopes there
15
15
  * shows a 75-group permission grid instead, and the person can stop on it without ever coming back. */
16
16
  serverScopes?: true;
17
+ /** Actions a research step may use by exact name, checked before the name rule: only while the connection's saved
18
+ * grant is read-only (readOnlyGrant). PostHog's server lists one action, `exec`, that reads or writes as the grant allows. */
19
+ readActions?: readonly string[];
17
20
  };
18
21
  /**
19
22
  * Services that connect this way: their servers speak streamable HTTP and
@@ -41,13 +44,18 @@ export declare const localConnectOf: (id: string) => {
41
44
  label: string;
42
45
  about: string;
43
46
  } | null;
44
- /** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
45
- export declare function connectionsOf(store: Store, repo: string): {
47
+ /**
48
+ * Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way.
49
+ * `local` connects with no sign-in. `research` says, for a connected service research can't use because of the access
50
+ * it was granted, why (shown on its Tools entry beside Reconnect read-only).
51
+ */
52
+ export declare function connectionsOf(store: Store, repo: string, home?: string): {
46
53
  id: string;
47
54
  label: string;
48
55
  about: string;
49
56
  state: "connected" | "open" | "taken";
50
57
  local?: true;
58
+ research?: string;
51
59
  }[];
52
60
  /**
53
61
  * Whether an action only reads: its name has a reading verb, and every other
@@ -55,13 +63,21 @@ export declare function connectionsOf(store: Store, repo: string): {
55
63
  * research read. An allow-list: a word nobody listed withholds the action.
56
64
  */
57
65
  export declare function readsOnly(action: string, subjects: readonly string[]): boolean;
66
+ export declare const readScope: (scope: string) => boolean;
67
+ /** Whether a saved grant only reads: it names at least one scope and every one is a read or identity scope. Unknown (null) never is. */
68
+ export declare const readOnlyGrant: (grant: readonly string[] | null) => boolean;
69
+ /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
70
+ export declare function scopeList(value: unknown): string[] | null;
71
+ /** The scope a connected service's sign-in was granted, as saved beside its token; null when it was never proved. */
72
+ export declare function grantOf(repo: string, tool: string, home?: string): string[] | null;
58
73
  /**
59
74
  * What a research step may use of a project's connected services: each
60
75
  * signed-in service's read-only actions, as its last test listed them —
61
76
  * by server (`reads`, for Codex's `enabled_tools`) and named the way Claude
62
77
  * allows them (`mcp__<service>__<action>`). `local` is the loopback host of
63
78
  * each app on this computer it reads (its proxy lets only those through). A service not connected, without
64
- * read-only actions, or left out of this run (`launched`) gives nothing.
79
+ * read-only actions, or left out of this run (`launched`) gives nothing. `withheld` is why a service that can name
80
+ * its research actions gave none (its grant, `granted`, writes or was never proved): the run says so.
65
81
  */
66
82
  export type ResearchTools = {
67
83
  services: {
@@ -71,8 +87,9 @@ export type ResearchTools = {
71
87
  allowed: string[];
72
88
  reads: Record<string, string[]>;
73
89
  local: string[];
90
+ withheld?: Record<string, string>;
74
91
  };
75
- export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null): ResearchTools;
92
+ export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null, granted?: (tool: string) => readonly string[] | null): ResearchTools;
76
93
  /** Where a service sends the person back after they sign in. */
77
94
  export declare const CONNECT_CALLBACK = "/settings/tools/connected";
78
95
  type Fetch = typeof fetch;
@@ -97,6 +114,8 @@ export type ConnectVisit = {
97
114
  resource: string;
98
115
  redirect: string;
99
116
  expires: number;
117
+ /** The scope asked for, when one was: the grant when the token answer leaves `scope` out (RFC 6749 §5.1). */
118
+ scope?: string | null;
100
119
  };
101
120
  /** A server's sign-in: the resource metadata its 401 names (or the well-known one), then its authorization server's metadata. */
102
121
  export declare function discoverSignIn(mcpUrl: string, fetcher?: Fetch): Promise<Server | null>;
@@ -112,6 +131,7 @@ export declare function startConnect(input: {
112
131
  origin: string;
113
132
  kit?: string | null;
114
133
  template?: string | null;
134
+ readOnly?: boolean;
115
135
  }, fetcher?: Fetch, now?: number): Promise<{
116
136
  ok: true;
117
137
  go: string;
@@ -52,7 +52,7 @@ export const ONE_CLICK = [
52
52
  // Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
53
53
  { id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
54
54
  reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
55
- { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.", serverScopes: true,
55
+ { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.", serverScopes: true, readActions: ["exec"],
56
56
  reads: ["insight", "run", "event", "definition", "property", "properties"] },
57
57
  { id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
58
58
  reads: ["uptime", "monitor", "incident", "availability", "response", "time"] },
@@ -75,7 +75,7 @@ export function oneClickServices(environment = process.env) {
75
75
  if (!/^[a-z0-9-]{1,40}$/.test(id) || label === "" || !loopback(url))
76
76
  return ONE_CLICK;
77
77
  const known = ONE_CLICK.find(one => one.id === id);
78
- const standIn = { id, label, url, about: known?.about ?? "A service on this computer.", ...(known?.reads === undefined ? {} : { reads: known.reads }) };
78
+ const standIn = { id, label, url, about: known?.about ?? "A service on this computer.", ...(known?.reads === undefined ? {} : { reads: known.reads }), ...(known?.readActions === undefined ? {} : { readActions: known.readActions }) };
79
79
  return ONE_CLICK.some(one => one.id === id) ? ONE_CLICK.map(one => one.id === id ? standIn : one) : [...ONE_CLICK, standIn];
80
80
  }
81
81
  export const oneClickOf = (id) => oneClickServices().find(one => one.id === id) ?? null;
@@ -86,12 +86,18 @@ export const localConnectOf = (id) => {
86
86
  const app = LOCAL_APPS.find(one => one.tool === id), tool = catalogTool(id);
87
87
  return app === undefined || tool === null ? null : { id, label: tool.label, about: app.connect };
88
88
  };
89
- /** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
90
- export function connectionsOf(store, repo) {
89
+ /**
90
+ * Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way.
91
+ * `local` connects with no sign-in. `research` says, for a connected service research can't use because of the access
92
+ * it was granted, why (shown on its Tools entry beside Reconnect read-only).
93
+ */
94
+ export function connectionsOf(store, repo, home = homedir()) {
91
95
  const tools = projectToolsOf(store, repo);
92
96
  const signIn = oneClickServices().map(service => {
93
97
  const had = tools.find(one => one.name === service.id);
94
- return { id: service.id, label: service.label, about: service.about, state: had === undefined ? "open" : had.spec.url === service.url && had.spec.bearer === ACCESS ? "connected" : "taken" };
98
+ const state = had === undefined ? "open" : had.spec.url === service.url && had.spec.bearer === ACCESS ? "connected" : "taken";
99
+ const withheld = state === "connected" && service.readActions !== undefined ? researchAccess(service, had, grantOf(repo, service.id, home)).withheld : null;
100
+ return { id: service.id, label: service.label, about: service.about, state, ...(withheld === null ? {} : { research: withheld.said }) };
95
101
  });
96
102
  const local = LOCAL_APPS.flatMap(app => {
97
103
  const one = localConnectOf(app.tool);
@@ -119,18 +125,62 @@ export function readsOnly(action, subjects) {
119
125
  const subject = (word) => subjects.includes(word) || subjects.includes(word.replace(/s$/, ""));
120
126
  return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
121
127
  }
122
- export function researchToolsOf(tools, launched = null) {
128
+ /**
129
+ * Identity scopes a read-only grant may carry beside `<resource>:read` ones. An allow-list: any other scope (a
130
+ * write, a wildcard, `introspection`, one nobody listed) makes the grant unsafe for research.
131
+ */
132
+ const IDENTITY_SCOPES = new Set(["openid", "profile", "email"]);
133
+ const READ_SCOPE = /^[a-z][a-z0-9_]{0,63}:read$/;
134
+ export const readScope = (scope) => IDENTITY_SCOPES.has(scope) || READ_SCOPE.test(scope);
135
+ /** Whether a saved grant only reads: it names at least one scope and every one is a read or identity scope. Unknown (null) never is. */
136
+ export const readOnlyGrant = (grant) => grant !== null && grant.length > 0 && grant.every(readScope);
137
+ /** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
138
+ export function scopeList(value) {
139
+ if (typeof value !== "string")
140
+ return null;
141
+ const tokens = value.split(" ").filter(one => one !== "");
142
+ if (tokens.length === 0 || tokens.length > 512 || !tokens.every(one => SCOPE_TOKEN.test(one)))
143
+ return null;
144
+ return [...new Set(tokens)].sort();
145
+ }
146
+ /** The scope a connected service's sign-in was granted, as saved beside its token; null when it was never proved. */
147
+ export function grantOf(repo, tool, home = homedir()) {
148
+ try {
149
+ return scopeList(readToolSecrets(repo, tool, home)[GRANTED]);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /**
156
+ * What research may use of one connected service: its exact `readActions` while the saved grant only reads, and
157
+ * otherwise (as for every service) the actions whose names only read. A service with `readActions` that gives
158
+ * research nothing says why, plainly, for the run and its Tools entry.
159
+ */
160
+ function researchAccess(service, tool, grant) {
161
+ const listed = tool.lastTest?.ok ? tool.lastTest.tools : [];
162
+ const exact = service.readActions !== undefined && readOnlyGrant(grant) ? service.readActions : [];
163
+ const reads = listed.filter(action => exact.includes(action) || (service.reads !== undefined && readsOnly(action, service.reads)));
164
+ if (reads.length > 0 || service.readActions === undefined || listed.length === 0 || readOnlyGrant(grant))
165
+ return { reads, withheld: null };
166
+ const why = grant === null ? `${service.label} didn't say what access it granted, so research runs can't use it`
167
+ : `${service.label} is connected with write access, so research runs can't use it`;
168
+ return { reads, withheld: { said: `${why}.`, reason: `${why}; reconnect read-only` } };
169
+ }
170
+ export function researchToolsOf(tools, launched = null, granted = () => null) {
123
171
  const research = { services: [], allowed: [], reads: {}, local: [] };
124
172
  // Signed-in services, then apps on this computer (each exactly as listed: its own address, no key).
125
173
  const readable = [
126
- ...oneClickServices().map(service => ({ id: service.id, label: service.label, reads: service.reads, is: (spec) => spec.url === service.url && spec.bearer === ACCESS })),
127
- ...LOCAL_APPS.map(app => ({ id: app.tool, label: catalogTool(app.tool)?.label ?? app.tool, reads: app.reads, is: (spec) => localAppOf(spec) === app })),
174
+ ...oneClickServices().map(service => ({ id: service.id, label: service.label, reads: service.reads, readActions: service.readActions, is: (spec) => spec.url === service.url && spec.bearer === ACCESS })),
175
+ ...LOCAL_APPS.map(app => ({ id: app.tool, label: catalogTool(app.tool)?.label ?? app.tool, reads: app.reads, readActions: undefined, is: (spec) => localAppOf(spec) === app })),
128
176
  ];
129
177
  for (const service of readable) {
130
178
  const tool = tools.find(one => one.name === service.id);
131
- if (service.reads === undefined || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
179
+ if ((service.reads === undefined && service.readActions === undefined) || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
132
180
  continue;
133
- const reads = (tool.lastTest?.ok ? tool.lastTest.tools : []).filter(action => readsOnly(action, service.reads));
181
+ const { reads, withheld } = researchAccess(service, tool, service.readActions === undefined ? null : granted(tool.name));
182
+ if (withheld !== null)
183
+ research.withheld = { ...research.withheld, [service.id]: withheld.reason };
134
184
  if (reads.length === 0)
135
185
  continue;
136
186
  research.services.push({ id: service.id, label: service.label });
@@ -143,6 +193,8 @@ export function researchToolsOf(tools, launched = null) {
143
193
  }
144
194
  /** The secrets a connected tool keeps: the bearer the MCP server takes, and what refreshing it needs. */
145
195
  const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
196
+ /** The scope the sign-in granted, kept beside its token. Not one of the spec's secrets, so it never reaches a launch. */
197
+ const GRANTED = "OAUTH_GRANTED_SCOPE";
146
198
  /** Where a service sends the person back after they sign in. */
147
199
  export const CONNECT_CALLBACK = "/settings/tools/connected";
148
200
  const VISIT_MS = 15 * 60_000, RENEW_RETRY_MS = 5 * 60_000;
@@ -252,9 +304,16 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
252
304
  go.searchParams.set("code_challenge_method", "S256");
253
305
  go.searchParams.set("state", state);
254
306
  go.searchParams.set("resource", resource);
255
- if (server.scopes.length > 0 && service.serverScopes !== true)
256
- go.searchParams.set("scope", server.scopes.join(" "));
257
- return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS } };
307
+ // Reconnect read-only (a service that names its research actions): only the read and identity scopes the server
308
+ // lists. Otherwise every listed scope, or none where the server applies its own preset.
309
+ const scopes = input.readOnly === true && service.readActions !== undefined ? server.scopes.filter(readScope)
310
+ : service.serverScopes === true ? [] : server.scopes;
311
+ if (input.readOnly === true && service.readActions !== undefined && scopes.length === 0)
312
+ return { ok: false, said: `${service.label} didn't list any read-only access to ask for.` };
313
+ const scope = scopes.length > 0 ? scopes.join(" ") : null;
314
+ if (scope !== null)
315
+ go.searchParams.set("scope", scope);
316
+ return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS, scope } };
258
317
  }
259
318
  /** The spec a connected service joins the project with: its MCP address, signed in with the token its sign-in gave. */
260
319
  export function connectedSpec(serviceId) {
@@ -300,7 +359,9 @@ export async function finishConnect(store, visit, code, now, options = {}) {
300
359
  if (!added.ok)
301
360
  return { ok: false, said: added.message };
302
361
  }
303
- setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource,
362
+ // The grant: what the answer says, or what was asked for when it says nothing; never one from an earlier sign-in.
363
+ const granted = "scope" in body ? scopeList(body["scope"]) : scopeList(visit.scope ?? null);
364
+ setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource, [GRANTED]: granted?.join(" ") ?? "",
304
365
  ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
305
366
  // The success line names the project it went to: the page and chat may be looking at another.
306
367
  const to = basename(visit.repo);
@@ -336,7 +397,9 @@ export async function refreshConnections(store, repos, now, options = {}) {
336
397
  report.problems.push(`${tool.name}: its sign-in ran out; connect it again on the Tools page`);
337
398
  continue;
338
399
  }
339
- setToolSecrets(repo, tool.name, { [ACCESS]: tokens.access, ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }) }, home);
400
+ // A refresh carries the same grant or a narrower one, and says so when it changes; said badly, it is unknown.
401
+ setToolSecrets(repo, tool.name, { [ACCESS]: tokens.access, ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }),
402
+ ...("scope" in body ? { [GRANTED]: scopeList(body["scope"])?.join(" ") ?? "" } : {}) }, home);
340
403
  renewTried.delete(key);
341
404
  report.refreshed++;
342
405
  }