canvas-parent-mcp 1.2.0 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/auth.js +84 -55
- package/dist/bundle.js +369 -130
- package/dist/client.js +28 -21
- package/dist/index.js +2 -2
- package/package.json +4 -4
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Canvas LMS (Instructure) — courses, grades, assignments, announcements, planner, conversations, files",
|
|
10
|
-
"version": "1.2.
|
|
10
|
+
"version": "1.2.3"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Canvas LMS",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
|
|
18
|
-
"version": "1.2.
|
|
18
|
+
"version": "1.2.3",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-parent-mcp",
|
|
3
3
|
"displayName": "Canvas LMS",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.3",
|
|
5
5
|
"description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
package/dist/auth.js
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
// - `@fetchproxy/bootstrap` is mocked at the module boundary in tests.
|
|
55
55
|
// - `loadAccount()` (the existing env-var resolver) is reused as-is so
|
|
56
56
|
// the legacy paths keep working unchanged.
|
|
57
|
-
import {
|
|
57
|
+
import { createSessionLifter } from '@fetchproxy/bootstrap';
|
|
58
58
|
import { classifyBridgeError } from '@fetchproxy/server';
|
|
59
59
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
60
60
|
import { loadAccount } from './config.js';
|
|
@@ -98,6 +98,12 @@ export async function resolveAuth() {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
// ── Path 4: fetchproxy fallback.
|
|
101
|
+
//
|
|
102
|
+
// NOTE: the browser is NOT read here. resolveAuth() runs once at process
|
|
103
|
+
// start; the lift is handed to the client as `refresh` and runs lazily on
|
|
104
|
+
// the first request and again on every 401. A cookie captured at boot made
|
|
105
|
+
// a 401 terminal — the account has empty creds, so there was nothing to
|
|
106
|
+
// re-mint with and only a restart recovered.
|
|
101
107
|
if (!fetchproxyDisabled()) {
|
|
102
108
|
// CANVAS_BASE_URL is guaranteed valid here — loadAccount() validates it
|
|
103
109
|
// before throwing the NO_ENV_CONFIG_MARKER error we caught above.
|
|
@@ -113,60 +119,22 @@ export async function resolveAuth() {
|
|
|
113
119
|
: baseHost;
|
|
114
120
|
const name = readEnv('CANVAS_NAME') ?? baseHost;
|
|
115
121
|
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
'Sign into your Canvas instance in the browser ' +
|
|
133
|
-
'(with the fetchproxy extension installed) and retry.');
|
|
134
|
-
}
|
|
135
|
-
const cookie = `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
|
|
136
|
-
// Synthesize a session account with empty creds — the client will see
|
|
137
|
-
// `preloaded` and skip the form login. This mirrors the cookie shape
|
|
138
|
-
// the legacy session-scrape path produces, so everything downstream
|
|
139
|
-
// (Cookie header, 401-retry, file downloads) keeps working.
|
|
140
|
-
const account = {
|
|
141
|
-
mode: 'session',
|
|
142
|
-
name,
|
|
143
|
-
baseUrl: cleanBaseUrl,
|
|
144
|
-
username: '',
|
|
145
|
-
password: '',
|
|
146
|
-
};
|
|
147
|
-
return {
|
|
148
|
-
account,
|
|
149
|
-
preloaded: { cookie },
|
|
150
|
-
source: 'fetchproxy',
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
catch (e) {
|
|
154
|
-
// 0.8.0+ typed-error discrimination. The fetchproxy server already
|
|
155
|
-
// retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
|
|
156
|
-
// a thrown FetchproxyBridgeDownError means the retry also failed —
|
|
157
|
-
// the extension's service worker is genuinely down and the user
|
|
158
|
-
// needs to wake it. The `.hint` is the actionable copy
|
|
159
|
-
// ("click the extension toolbar icon...") that we'd otherwise have
|
|
160
|
-
// to hand-write here. Surface it verbatim so users in path 4 get
|
|
161
|
-
// the same self-service guidance as path 5.
|
|
162
|
-
if (classifyBridgeError(e) === 'bridge_down') {
|
|
163
|
-
const downErr = e;
|
|
164
|
-
throw new Error(`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
|
|
165
|
-
}
|
|
166
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
167
|
-
throw new Error(`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, ` +
|
|
168
|
-
`and fetchproxy fallback failed: ${msg}`);
|
|
169
|
-
}
|
|
122
|
+
// Synthesized session account with empty creds — the client sees `refresh`
|
|
123
|
+
// and skips the form login. Cookie shape matches the legacy session-scrape
|
|
124
|
+
// path so everything downstream (Cookie header, 401 replay, downloads)
|
|
125
|
+
// keeps working.
|
|
126
|
+
const account = {
|
|
127
|
+
mode: 'session',
|
|
128
|
+
name,
|
|
129
|
+
baseUrl: cleanBaseUrl,
|
|
130
|
+
username: '',
|
|
131
|
+
password: '',
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
account,
|
|
135
|
+
refresh: () => liftBrowserCookie(declaredDomain, baseHost),
|
|
136
|
+
source: 'fetchproxy',
|
|
137
|
+
};
|
|
170
138
|
}
|
|
171
139
|
// ── Path 5: nothing configured and fetchproxy explicitly disabled.
|
|
172
140
|
throw new Error('Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), ' +
|
|
@@ -175,3 +143,64 @@ export async function resolveAuth() {
|
|
|
175
143
|
'or install the fetchproxy extension and sign into your Canvas instance ' +
|
|
176
144
|
'(unset CANVAS_DISABLE_FETCHPROXY if it is set).');
|
|
177
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* One lifter per declared domain, built lazily and reused.
|
|
148
|
+
*
|
|
149
|
+
* The scope is not static — self-hosted Canvas declares its literal hostname
|
|
150
|
+
* while *.instructure.com tenants share one wildcard — so the lifter cannot be
|
|
151
|
+
* a module-level constant. Caching is load-bearing rather than an
|
|
152
|
+
* optimization: `createSessionLifter` single-flights concurrent calls, and
|
|
153
|
+
* that only helps if renewals share ONE lifter instead of constructing a fresh
|
|
154
|
+
* one per call.
|
|
155
|
+
*/
|
|
156
|
+
const liftersByDomain = new Map();
|
|
157
|
+
function lifterFor(declaredDomain) {
|
|
158
|
+
let lift = liftersByDomain.get(declaredDomain);
|
|
159
|
+
if (!lift) {
|
|
160
|
+
lift = createSessionLifter({
|
|
161
|
+
serverName: pkg.name,
|
|
162
|
+
version: pkg.version,
|
|
163
|
+
domains: [declaredDomain],
|
|
164
|
+
declare: {
|
|
165
|
+
cookies: ['canvas_session', 'pseudonym_credentials'],
|
|
166
|
+
localStorage: [],
|
|
167
|
+
sessionStorage: [],
|
|
168
|
+
captureHeaders: [],
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
liftersByDomain.set(declaredDomain, lift);
|
|
172
|
+
}
|
|
173
|
+
return lift;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Lift a fresh Canvas session cookie out of the user's signed-in tab.
|
|
177
|
+
*
|
|
178
|
+
* Runs on every mint, not once at startup — fetchproxy is not in the request
|
|
179
|
+
* hot path, only the renewal path.
|
|
180
|
+
*/
|
|
181
|
+
async function liftBrowserCookie(declaredDomain, baseHost) {
|
|
182
|
+
try {
|
|
183
|
+
const session = await lifterFor(declaredDomain)();
|
|
184
|
+
const canvasSession = session.cookies['canvas_session'];
|
|
185
|
+
const pseudoCreds = session.cookies['pseudonym_credentials'];
|
|
186
|
+
if (!canvasSession || !pseudoCreds) {
|
|
187
|
+
throw new Error(`required cookies not found on ${baseHost}. ` +
|
|
188
|
+
'Sign into your Canvas instance in the browser ' +
|
|
189
|
+
'(with the fetchproxy extension installed) and retry.');
|
|
190
|
+
}
|
|
191
|
+
return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
// 0.8.0+ typed-error discrimination. The fetchproxy server already retries
|
|
195
|
+
// once on SW eviction, so a thrown FetchproxyBridgeDownError means the
|
|
196
|
+
// retry also failed — the extension's service worker is genuinely down and
|
|
197
|
+
// the user needs to wake it. `.hint` is the actionable copy.
|
|
198
|
+
if (classifyBridgeError(e) === 'bridge_down') {
|
|
199
|
+
const downErr = e;
|
|
200
|
+
throw new Error(`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
|
|
201
|
+
}
|
|
202
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
203
|
+
throw new Error(`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, ` +
|
|
204
|
+
`and fetchproxy lift failed: ${msg}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
package/dist/bundle.js
CHANGED
|
@@ -3776,6 +3776,7 @@ var require_fast_uri = __commonJS({
|
|
|
3776
3776
|
return uriTokens.join("");
|
|
3777
3777
|
}
|
|
3778
3778
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3779
|
+
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
3779
3780
|
function getParseError(parsed, matches) {
|
|
3780
3781
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3781
3782
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -3805,6 +3806,11 @@ var require_fast_uri = __commonJS({
|
|
|
3805
3806
|
uri = "//" + uri;
|
|
3806
3807
|
}
|
|
3807
3808
|
}
|
|
3809
|
+
const authorityMatch = uri.match(AUTHORITY_PREFIX);
|
|
3810
|
+
if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
|
|
3811
|
+
parsed.error = "URI authority must not contain a literal backslash.";
|
|
3812
|
+
malformedAuthorityOrPort = true;
|
|
3813
|
+
}
|
|
3808
3814
|
const matches = uri.match(URI_PARSE);
|
|
3809
3815
|
if (matches) {
|
|
3810
3816
|
parsed.scheme = matches[1];
|
|
@@ -3848,7 +3854,7 @@ var require_fast_uri = __commonJS({
|
|
|
3848
3854
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
|
|
3849
3855
|
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
|
|
3850
3856
|
try {
|
|
3851
|
-
parsed.host = URL
|
|
3857
|
+
parsed.host = new URL("http://" + parsed.host).hostname;
|
|
3852
3858
|
} catch (e) {
|
|
3853
3859
|
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
|
|
3854
3860
|
}
|
|
@@ -7693,6 +7699,7 @@ var require_receiver = __commonJS({
|
|
|
7693
7699
|
this._opcode = 0;
|
|
7694
7700
|
this._totalPayloadLength = 0;
|
|
7695
7701
|
this._messageLength = 0;
|
|
7702
|
+
this._numFragments = 0;
|
|
7696
7703
|
this._fragments = [];
|
|
7697
7704
|
this._errored = false;
|
|
7698
7705
|
this._loop = false;
|
|
@@ -8043,23 +8050,23 @@ var require_receiver = __commonJS({
|
|
|
8043
8050
|
this.controlMessage(data, cb);
|
|
8044
8051
|
return;
|
|
8045
8052
|
}
|
|
8053
|
+
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
|
|
8054
|
+
const error51 = this.createError(
|
|
8055
|
+
RangeError,
|
|
8056
|
+
"Too many message fragments",
|
|
8057
|
+
false,
|
|
8058
|
+
1008,
|
|
8059
|
+
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8060
|
+
);
|
|
8061
|
+
cb(error51);
|
|
8062
|
+
return;
|
|
8063
|
+
}
|
|
8046
8064
|
if (this._compressed) {
|
|
8047
8065
|
this._state = INFLATING;
|
|
8048
8066
|
this.decompress(data, cb);
|
|
8049
8067
|
return;
|
|
8050
8068
|
}
|
|
8051
8069
|
if (data.length) {
|
|
8052
|
-
if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
|
|
8053
|
-
const error51 = this.createError(
|
|
8054
|
-
RangeError,
|
|
8055
|
-
"Too many message fragments",
|
|
8056
|
-
false,
|
|
8057
|
-
1008,
|
|
8058
|
-
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8059
|
-
);
|
|
8060
|
-
cb(error51);
|
|
8061
|
-
return;
|
|
8062
|
-
}
|
|
8063
8070
|
this._messageLength = this._totalPayloadLength;
|
|
8064
8071
|
this._fragments.push(data);
|
|
8065
8072
|
}
|
|
@@ -8089,17 +8096,6 @@ var require_receiver = __commonJS({
|
|
|
8089
8096
|
cb(error51);
|
|
8090
8097
|
return;
|
|
8091
8098
|
}
|
|
8092
|
-
if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
|
|
8093
|
-
const error51 = this.createError(
|
|
8094
|
-
RangeError,
|
|
8095
|
-
"Too many message fragments",
|
|
8096
|
-
false,
|
|
8097
|
-
1008,
|
|
8098
|
-
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8099
|
-
);
|
|
8100
|
-
cb(error51);
|
|
8101
|
-
return;
|
|
8102
|
-
}
|
|
8103
8099
|
this._fragments.push(buf);
|
|
8104
8100
|
}
|
|
8105
8101
|
this.dataMessage(cb);
|
|
@@ -8122,6 +8118,7 @@ var require_receiver = __commonJS({
|
|
|
8122
8118
|
this._totalPayloadLength = 0;
|
|
8123
8119
|
this._messageLength = 0;
|
|
8124
8120
|
this._fragmented = 0;
|
|
8121
|
+
this._numFragments = 0;
|
|
8125
8122
|
this._fragments = [];
|
|
8126
8123
|
if (this._opcode === 2) {
|
|
8127
8124
|
let data;
|
|
@@ -9622,8 +9619,8 @@ var require_websocket = __commonJS({
|
|
|
9622
9619
|
autoPong: true,
|
|
9623
9620
|
closeTimeout: CLOSE_TIMEOUT,
|
|
9624
9621
|
protocolVersion: protocolVersions[1],
|
|
9625
|
-
maxBufferedChunks:
|
|
9626
|
-
maxFragments:
|
|
9622
|
+
maxBufferedChunks: 256 * 1024,
|
|
9623
|
+
maxFragments: 16 * 1024,
|
|
9627
9624
|
maxPayload: 100 * 1024 * 1024,
|
|
9628
9625
|
skipUTF8Validation: false,
|
|
9629
9626
|
perMessageDeflate: true,
|
|
@@ -10210,9 +10207,9 @@ var require_websocket_server = __commonJS({
|
|
|
10210
10207
|
* called
|
|
10211
10208
|
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
|
10212
10209
|
* @param {String} [options.host] The hostname where to bind the server
|
|
10213
|
-
* @param {Number} [options.maxBufferedChunks=
|
|
10210
|
+
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
|
10214
10211
|
* buffered data chunks
|
|
10215
|
-
* @param {Number} [options.maxFragments=
|
|
10212
|
+
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
|
10216
10213
|
* fragments
|
|
10217
10214
|
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
|
10218
10215
|
* size
|
|
@@ -10235,8 +10232,8 @@ var require_websocket_server = __commonJS({
|
|
|
10235
10232
|
options = {
|
|
10236
10233
|
allowSynchronousEvents: true,
|
|
10237
10234
|
autoPong: true,
|
|
10238
|
-
maxBufferedChunks:
|
|
10239
|
-
maxFragments:
|
|
10235
|
+
maxBufferedChunks: 256 * 1024,
|
|
10236
|
+
maxFragments: 16 * 1024,
|
|
10240
10237
|
maxPayload: 100 * 1024 * 1024,
|
|
10241
10238
|
skipUTF8Validation: false,
|
|
10242
10239
|
perMessageDeflate: false,
|
|
@@ -26732,17 +26729,33 @@ function normalizeObjectSchema(schema) {
|
|
|
26732
26729
|
}
|
|
26733
26730
|
return void 0;
|
|
26734
26731
|
}
|
|
26732
|
+
function getDotPath(path) {
|
|
26733
|
+
if (path.length === 0) {
|
|
26734
|
+
return "object root";
|
|
26735
|
+
}
|
|
26736
|
+
return path.reduce((acc, seg, index) => {
|
|
26737
|
+
if (index === 0) {
|
|
26738
|
+
return String(seg);
|
|
26739
|
+
}
|
|
26740
|
+
if (typeof seg === "number") {
|
|
26741
|
+
return `${acc}[${seg}]`;
|
|
26742
|
+
}
|
|
26743
|
+
return `${acc}.${seg}`;
|
|
26744
|
+
}, "");
|
|
26745
|
+
}
|
|
26735
26746
|
function getParseErrorMessage(error51) {
|
|
26736
26747
|
if (error51 && typeof error51 === "object") {
|
|
26748
|
+
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
26749
|
+
return error51.issues.map((i) => {
|
|
26750
|
+
if (!i.path?.length) {
|
|
26751
|
+
return i.message;
|
|
26752
|
+
}
|
|
26753
|
+
return `${i.message} at ${getDotPath(i.path)}`;
|
|
26754
|
+
}).join("\n");
|
|
26755
|
+
}
|
|
26737
26756
|
if ("message" in error51 && typeof error51.message === "string") {
|
|
26738
26757
|
return error51.message;
|
|
26739
26758
|
}
|
|
26740
|
-
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
26741
|
-
const firstIssue = error51.issues[0];
|
|
26742
|
-
if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
|
|
26743
|
-
return String(firstIssue.message);
|
|
26744
|
-
}
|
|
26745
|
-
}
|
|
26746
26759
|
try {
|
|
26747
26760
|
return JSON.stringify(error51);
|
|
26748
26761
|
} catch {
|
|
@@ -33357,16 +33370,7 @@ var Server = class extends Protocol {
|
|
|
33357
33370
|
if (!methodSchema) {
|
|
33358
33371
|
throw new Error("Schema is missing a method literal");
|
|
33359
33372
|
}
|
|
33360
|
-
|
|
33361
|
-
if (isZ4Schema(methodSchema)) {
|
|
33362
|
-
const v4Schema = methodSchema;
|
|
33363
|
-
const v4Def = v4Schema._zod?.def;
|
|
33364
|
-
methodValue = v4Def?.value ?? v4Schema.value;
|
|
33365
|
-
} else {
|
|
33366
|
-
const v3Schema = methodSchema;
|
|
33367
|
-
const legacyDef = v3Schema._def;
|
|
33368
|
-
methodValue = legacyDef?.value ?? v3Schema.value;
|
|
33369
|
-
}
|
|
33373
|
+
const methodValue = getLiteralValue(methodSchema);
|
|
33370
33374
|
if (typeof methodValue !== "string") {
|
|
33371
33375
|
throw new Error("Schema method literal must be a string");
|
|
33372
33376
|
}
|
|
@@ -34554,8 +34558,17 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
34554
34558
|
import process3 from "node:process";
|
|
34555
34559
|
|
|
34556
34560
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
|
|
34561
|
+
var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
34557
34562
|
var ReadBuffer = class {
|
|
34563
|
+
constructor(options) {
|
|
34564
|
+
this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
34565
|
+
}
|
|
34558
34566
|
append(chunk2) {
|
|
34567
|
+
const newSize = (this._buffer?.length ?? 0) + chunk2.length;
|
|
34568
|
+
if (newSize > this._maxBufferSize) {
|
|
34569
|
+
this.clear();
|
|
34570
|
+
throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
|
|
34571
|
+
}
|
|
34559
34572
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk2]) : chunk2;
|
|
34560
34573
|
}
|
|
34561
34574
|
readMessage() {
|
|
@@ -34583,18 +34596,24 @@ function serializeMessage(message) {
|
|
|
34583
34596
|
|
|
34584
34597
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
|
|
34585
34598
|
var StdioServerTransport = class {
|
|
34586
|
-
constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
|
|
34599
|
+
constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
|
|
34587
34600
|
this._stdin = _stdin;
|
|
34588
34601
|
this._stdout = _stdout;
|
|
34589
|
-
this._readBuffer = new ReadBuffer();
|
|
34590
34602
|
this._started = false;
|
|
34591
34603
|
this._ondata = (chunk2) => {
|
|
34592
|
-
|
|
34593
|
-
|
|
34604
|
+
try {
|
|
34605
|
+
this._readBuffer.append(chunk2);
|
|
34606
|
+
this.processReadBuffer();
|
|
34607
|
+
} catch (error51) {
|
|
34608
|
+
this.onerror?.(error51);
|
|
34609
|
+
this.close().catch(() => {
|
|
34610
|
+
});
|
|
34611
|
+
}
|
|
34594
34612
|
};
|
|
34595
34613
|
this._onerror = (error51) => {
|
|
34596
34614
|
this.onerror?.(error51);
|
|
34597
34615
|
};
|
|
34616
|
+
this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
|
|
34598
34617
|
}
|
|
34599
34618
|
/**
|
|
34600
34619
|
* Starts listening for messages on stdin.
|
|
@@ -34990,7 +35009,8 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
34990
35009
|
"capture_redirect",
|
|
34991
35010
|
"read_indexed_db",
|
|
34992
35011
|
"read_dom",
|
|
34993
|
-
"download"
|
|
35012
|
+
"download",
|
|
35013
|
+
"graphql"
|
|
34994
35014
|
]);
|
|
34995
35015
|
|
|
34996
35016
|
// node_modules/@fetchproxy/protocol/dist/mcp-id.js
|
|
@@ -35293,6 +35313,7 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35293
35313
|
}
|
|
35294
35314
|
var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
|
|
35295
35315
|
var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
|
|
35316
|
+
var GRAPHQL_OP_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]{0,127}$/;
|
|
35296
35317
|
function assertDomSelectorsArray(value, label) {
|
|
35297
35318
|
if (!Array.isArray(value)) {
|
|
35298
35319
|
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
@@ -35329,6 +35350,37 @@ function assertDomSelectorsArray(value, label) {
|
|
|
35329
35350
|
}
|
|
35330
35351
|
}
|
|
35331
35352
|
}
|
|
35353
|
+
function assertGraphqlOpsArray(value, label) {
|
|
35354
|
+
if (!Array.isArray(value)) {
|
|
35355
|
+
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
35356
|
+
}
|
|
35357
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35358
|
+
for (let i = 0; i < value.length; i++) {
|
|
35359
|
+
const entry = value[i];
|
|
35360
|
+
assertObject(entry, `${label}[${i}]`);
|
|
35361
|
+
if (entry.name === void 0) {
|
|
35362
|
+
throw new ProtocolError(`${label}[${i}].name: missing`);
|
|
35363
|
+
}
|
|
35364
|
+
if (entry.operationName === void 0) {
|
|
35365
|
+
throw new ProtocolError(`${label}[${i}].operationName: missing`);
|
|
35366
|
+
}
|
|
35367
|
+
if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
|
|
35368
|
+
throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
|
|
35369
|
+
}
|
|
35370
|
+
if (typeof entry.operationName !== "string" || !GRAPHQL_OP_NAME_RE.test(entry.operationName)) {
|
|
35371
|
+
throw new ProtocolError(`${label}[${i}].operationName: invalid ${JSON.stringify(entry.operationName)}`);
|
|
35372
|
+
}
|
|
35373
|
+
if (seen.has(entry.name)) {
|
|
35374
|
+
throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
|
|
35375
|
+
}
|
|
35376
|
+
seen.add(entry.name);
|
|
35377
|
+
for (const k of Object.keys(entry)) {
|
|
35378
|
+
if (k !== "name" && k !== "operationName") {
|
|
35379
|
+
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
35380
|
+
}
|
|
35381
|
+
}
|
|
35382
|
+
}
|
|
35383
|
+
}
|
|
35332
35384
|
function validateFrame(raw) {
|
|
35333
35385
|
assertObject(raw, "frame");
|
|
35334
35386
|
const t = raw.type;
|
|
@@ -35407,6 +35459,9 @@ function validateHello(raw) {
|
|
|
35407
35459
|
if (raw.domSelectors !== void 0) {
|
|
35408
35460
|
assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
|
|
35409
35461
|
}
|
|
35462
|
+
if (raw.graphqlOps !== void 0) {
|
|
35463
|
+
assertGraphqlOpsArray(raw.graphqlOps, "hello.graphqlOps");
|
|
35464
|
+
}
|
|
35410
35465
|
assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
|
|
35411
35466
|
assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
|
|
35412
35467
|
assertBase64(raw.sessionNonce, "hello.sessionNonce");
|
|
@@ -35656,6 +35711,28 @@ function validateInnerRequest(raw) {
|
|
|
35656
35711
|
}
|
|
35657
35712
|
return raw;
|
|
35658
35713
|
}
|
|
35714
|
+
if (raw.op === "graphql_query") {
|
|
35715
|
+
assertObject(raw.init, "inner.init");
|
|
35716
|
+
if (raw.init.name === void 0)
|
|
35717
|
+
throw new ProtocolError("inner.init.name: missing");
|
|
35718
|
+
if (raw.init.variables === void 0) {
|
|
35719
|
+
throw new ProtocolError("inner.init.variables: missing");
|
|
35720
|
+
}
|
|
35721
|
+
assertString(raw.init.name, "inner.init.name");
|
|
35722
|
+
if (raw.init.name.length === 0) {
|
|
35723
|
+
throw new ProtocolError("inner.init.name: must be non-empty");
|
|
35724
|
+
}
|
|
35725
|
+
assertObject(raw.init.variables, "inner.init.variables");
|
|
35726
|
+
if (raw.init.tabUrl !== void 0) {
|
|
35727
|
+
assertString(raw.init.tabUrl, "inner.init.tabUrl");
|
|
35728
|
+
}
|
|
35729
|
+
for (const k of Object.keys(raw.init)) {
|
|
35730
|
+
if (k !== "name" && k !== "variables" && k !== "tabUrl") {
|
|
35731
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on graphql_query`);
|
|
35732
|
+
}
|
|
35733
|
+
}
|
|
35734
|
+
return raw;
|
|
35735
|
+
}
|
|
35659
35736
|
if (raw.op === "download") {
|
|
35660
35737
|
assertObject(raw.init, "inner.init");
|
|
35661
35738
|
if (raw.init.url === void 0) {
|
|
@@ -35681,7 +35758,7 @@ function validateInnerRequest(raw) {
|
|
|
35681
35758
|
}
|
|
35682
35759
|
return raw;
|
|
35683
35760
|
}
|
|
35684
|
-
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35761
|
+
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ${JSON.stringify(raw.op)}`);
|
|
35685
35762
|
}
|
|
35686
35763
|
function assertNonEmptyKeyArray(value, label) {
|
|
35687
35764
|
if (!Array.isArray(value)) {
|
|
@@ -35712,6 +35789,10 @@ function assertStringMap(value, label) {
|
|
|
35712
35789
|
}
|
|
35713
35790
|
}
|
|
35714
35791
|
}
|
|
35792
|
+
var KNOWN_RESPONSE_OPS = /* @__PURE__ */ new Set([
|
|
35793
|
+
...KNOWN_CAPABILITIES,
|
|
35794
|
+
"graphql_query"
|
|
35795
|
+
]);
|
|
35715
35796
|
function validateInnerResponse(raw) {
|
|
35716
35797
|
assertPositiveInt(raw.id, "inner.id");
|
|
35717
35798
|
if (raw.ok === true) {
|
|
@@ -35773,6 +35854,13 @@ function validateInnerResponse(raw) {
|
|
|
35773
35854
|
assertStringMap(raw.values, "inner.values");
|
|
35774
35855
|
return raw;
|
|
35775
35856
|
}
|
|
35857
|
+
if (op === "graphql_query") {
|
|
35858
|
+
if (raw.data === void 0) {
|
|
35859
|
+
throw new ProtocolError("inner.data: missing on graphql_query response");
|
|
35860
|
+
}
|
|
35861
|
+
assertObject(raw.data, "inner.data");
|
|
35862
|
+
return raw;
|
|
35863
|
+
}
|
|
35776
35864
|
if (op === "download") {
|
|
35777
35865
|
assertObject(raw.value, "inner.value");
|
|
35778
35866
|
assertString(raw.value.path, "inner.value.path");
|
|
@@ -35797,7 +35885,7 @@ function validateInnerResponse(raw) {
|
|
|
35797
35885
|
if (raw.ok === false) {
|
|
35798
35886
|
assertString(raw.error, "inner.error");
|
|
35799
35887
|
if (raw.op !== void 0) {
|
|
35800
|
-
if (typeof raw.op !== "string" || !
|
|
35888
|
+
if (typeof raw.op !== "string" || !KNOWN_RESPONSE_OPS.has(raw.op)) {
|
|
35801
35889
|
throw new ProtocolError(`inner.op: unknown response op ${JSON.stringify(raw.op)}`);
|
|
35802
35890
|
}
|
|
35803
35891
|
}
|
|
@@ -35989,11 +36077,33 @@ async function sealInnerFrame(sessionKey, mcpId, seq, inner) {
|
|
|
35989
36077
|
};
|
|
35990
36078
|
}
|
|
35991
36079
|
async function openEncryptedFrame(sessionKey, frame) {
|
|
35992
|
-
const
|
|
35993
|
-
|
|
35994
|
-
|
|
35995
|
-
|
|
35996
|
-
|
|
36080
|
+
const result = await openEncryptedFrameDetailed(sessionKey, frame);
|
|
36081
|
+
if (result.stage === "ok")
|
|
36082
|
+
return result.inner;
|
|
36083
|
+
throw result.error instanceof Error ? result.error : new Error(String(result.error));
|
|
36084
|
+
}
|
|
36085
|
+
async function openEncryptedFrameDetailed(sessionKey, frame) {
|
|
36086
|
+
let pt;
|
|
36087
|
+
try {
|
|
36088
|
+
const iv = fromB64(frame.iv);
|
|
36089
|
+
const ct = fromB64(frame.ciphertext);
|
|
36090
|
+
pt = await aesGcmOpen(sessionKey, iv, ct);
|
|
36091
|
+
} catch (error51) {
|
|
36092
|
+
return { stage: "decrypt-failed", error: error51 };
|
|
36093
|
+
}
|
|
36094
|
+
let parsed;
|
|
36095
|
+
try {
|
|
36096
|
+
parsed = JSON.parse(dec.decode(pt));
|
|
36097
|
+
} catch (error51) {
|
|
36098
|
+
return { stage: "validation-failed", error: error51, recoveredId: void 0 };
|
|
36099
|
+
}
|
|
36100
|
+
try {
|
|
36101
|
+
const inner = validateInnerFrame(parsed);
|
|
36102
|
+
return { stage: "ok", inner };
|
|
36103
|
+
} catch (error51) {
|
|
36104
|
+
const recoveredId = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.id === "number" && Number.isInteger(parsed.id) && parsed.id > 0 ? parsed.id : void 0;
|
|
36105
|
+
return { stage: "validation-failed", error: error51, recoveredId };
|
|
36106
|
+
}
|
|
35997
36107
|
}
|
|
35998
36108
|
|
|
35999
36109
|
// node_modules/@fetchproxy/server/dist/election.js
|
|
@@ -36119,6 +36229,12 @@ async function buildServerHello(opts) {
|
|
|
36119
36229
|
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36120
36230
|
}));
|
|
36121
36231
|
}
|
|
36232
|
+
if (opts.graphqlOps && opts.graphqlOps.length > 0) {
|
|
36233
|
+
hello.graphqlOps = opts.graphqlOps.map((d) => ({
|
|
36234
|
+
name: d.name,
|
|
36235
|
+
operationName: d.operationName
|
|
36236
|
+
}));
|
|
36237
|
+
}
|
|
36122
36238
|
return hello;
|
|
36123
36239
|
}
|
|
36124
36240
|
|
|
@@ -36209,7 +36325,8 @@ async function startHost(opts) {
|
|
|
36209
36325
|
indexedDbScopes: opts.ownIndexedDbScopes,
|
|
36210
36326
|
localStoragePointers: opts.ownLocalStoragePointers,
|
|
36211
36327
|
sessionStoragePointers: opts.ownSessionStoragePointers,
|
|
36212
|
-
domSelectors: opts.ownDomSelectors
|
|
36328
|
+
domSelectors: opts.ownDomSelectors,
|
|
36329
|
+
graphqlOps: opts.ownGraphqlOps
|
|
36213
36330
|
});
|
|
36214
36331
|
const ownSessionNonce = fromB64(ownHello.sessionNonce);
|
|
36215
36332
|
let extensionWs = null;
|
|
@@ -36446,7 +36563,8 @@ async function startPeer(opts) {
|
|
|
36446
36563
|
indexedDbScopes: opts.indexedDbScopes,
|
|
36447
36564
|
domSelectors: opts.domSelectors,
|
|
36448
36565
|
localStoragePointers: opts.localStoragePointers,
|
|
36449
|
-
sessionStoragePointers: opts.sessionStoragePointers
|
|
36566
|
+
sessionStoragePointers: opts.sessionStoragePointers,
|
|
36567
|
+
graphqlOps: opts.graphqlOps
|
|
36450
36568
|
});
|
|
36451
36569
|
const sessionNonce = fromB64(hello.sessionNonce);
|
|
36452
36570
|
ws.send(JSON.stringify(hello));
|
|
@@ -36490,10 +36608,20 @@ async function startPeer(opts) {
|
|
|
36490
36608
|
return;
|
|
36491
36609
|
if (!session.acceptInboundSeq(frame.seq))
|
|
36492
36610
|
return;
|
|
36493
|
-
|
|
36494
|
-
|
|
36495
|
-
innerListeners.forEach((cb) => cb(inner));
|
|
36496
|
-
}
|
|
36611
|
+
const result = await openEncryptedFrameDetailed(session.sessionKey, frame);
|
|
36612
|
+
if (result.stage === "ok") {
|
|
36613
|
+
innerListeners.forEach((cb) => cb(result.inner));
|
|
36614
|
+
} else if (result.stage === "decrypt-failed") {
|
|
36615
|
+
} else {
|
|
36616
|
+
console.error("[fetchproxy] peer: received a frame that decrypted OK but failed validation:", result.error);
|
|
36617
|
+
if (result.recoveredId !== void 0) {
|
|
36618
|
+
innerListeners.forEach((cb) => cb({
|
|
36619
|
+
type: "response",
|
|
36620
|
+
id: result.recoveredId,
|
|
36621
|
+
ok: false,
|
|
36622
|
+
error: `malformed response failed protocol validation: ${String(result.error)}`
|
|
36623
|
+
}));
|
|
36624
|
+
}
|
|
36497
36625
|
}
|
|
36498
36626
|
}
|
|
36499
36627
|
} catch (e) {
|
|
@@ -36770,6 +36898,10 @@ var FetchproxyServer = class {
|
|
|
36770
36898
|
pendingIdb = /* @__PURE__ */ new Map();
|
|
36771
36899
|
// download awaiters resolve the saved-file metadata (path + size + mime).
|
|
36772
36900
|
pendingDownload = /* @__PURE__ */ new Map();
|
|
36901
|
+
// 1.x+: graphql_query awaiters resolve the GraphQL `data` object. Its
|
|
36902
|
+
// shape is operation-specific, so the awaiter resolves `unknown` and the
|
|
36903
|
+
// caller narrows.
|
|
36904
|
+
pendingGraphql = /* @__PURE__ */ new Map();
|
|
36773
36905
|
mcpId = null;
|
|
36774
36906
|
identity = null;
|
|
36775
36907
|
// 0.5.3+: in-flight role-election / handle-start promise. Set the
|
|
@@ -36857,6 +36989,10 @@ var FetchproxyServer = class {
|
|
|
36857
36989
|
selector: d.selector,
|
|
36858
36990
|
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36859
36991
|
})),
|
|
36992
|
+
graphqlOps: (opts.graphqlOps ?? []).map((d) => ({
|
|
36993
|
+
name: d.name,
|
|
36994
|
+
operationName: d.operationName
|
|
36995
|
+
})),
|
|
36860
36996
|
// 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
|
|
36861
36997
|
// adapter was about to set these to the same numbers anyway; the
|
|
36862
36998
|
// back-door is `0` (explicit opt-out) if a caller genuinely wants
|
|
@@ -36978,6 +37114,7 @@ var FetchproxyServer = class {
|
|
|
36978
37114
|
ownLocalStoragePointers: this.opts.localStoragePointers,
|
|
36979
37115
|
ownSessionStoragePointers: this.opts.sessionStoragePointers,
|
|
36980
37116
|
ownDomSelectors: this.opts.domSelectors,
|
|
37117
|
+
ownGraphqlOps: this.opts.graphqlOps,
|
|
36981
37118
|
onPairCode: this.opts.onPairCode
|
|
36982
37119
|
});
|
|
36983
37120
|
this.hostHandle.onOwnInner((inner) => this.onInner(inner));
|
|
@@ -37006,7 +37143,8 @@ var FetchproxyServer = class {
|
|
|
37006
37143
|
indexedDbScopes: this.opts.indexedDbScopes,
|
|
37007
37144
|
localStoragePointers: this.opts.localStoragePointers,
|
|
37008
37145
|
sessionStoragePointers: this.opts.sessionStoragePointers,
|
|
37009
|
-
domSelectors: this.opts.domSelectors
|
|
37146
|
+
domSelectors: this.opts.domSelectors,
|
|
37147
|
+
graphqlOps: this.opts.graphqlOps
|
|
37010
37148
|
});
|
|
37011
37149
|
this.peerHandle.onInner((inner) => this.onInner(inner));
|
|
37012
37150
|
this.peerHandle.onRenegotiate(() => {
|
|
@@ -37212,6 +37350,7 @@ var FetchproxyServer = class {
|
|
|
37212
37350
|
this.pendingRedirect.delete(id);
|
|
37213
37351
|
this.pendingDownload.delete(id);
|
|
37214
37352
|
this.pendingIdb.delete(id);
|
|
37353
|
+
this.pendingGraphql.delete(id);
|
|
37215
37354
|
}
|
|
37216
37355
|
throw err;
|
|
37217
37356
|
}
|
|
@@ -38021,6 +38160,52 @@ var FetchproxyServer = class {
|
|
|
38021
38160
|
await this.sendInnerFrame(inner);
|
|
38022
38161
|
return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
|
|
38023
38162
|
}
|
|
38163
|
+
/**
|
|
38164
|
+
* 1.x+: run a declared GraphQL operation through the page's own Apollo
|
|
38165
|
+
* client (`window.__APOLLO_CLIENT__`) in the signed-in tab's MAIN world.
|
|
38166
|
+
* Requires `'graphql'` in capabilities AND `name` to match a declared
|
|
38167
|
+
* `graphqlOps` entry. The extension resolves `name` → `operationName` →
|
|
38168
|
+
* the live DocumentNode the page already observed, then invokes
|
|
38169
|
+
* `client.query({ query, variables })` — the site's own request path, so
|
|
38170
|
+
* per-request bot telemetry (Akamai etc.) runs automatically.
|
|
38171
|
+
*
|
|
38172
|
+
* Returns the GraphQL `data` object on success (shape is
|
|
38173
|
+
* operation-specific; the caller narrows). Throws a plain `Error` on
|
|
38174
|
+
* developer mistakes (undeclared capability, undeclared name) and a
|
|
38175
|
+
* descriptive `Error` on the `ok:false` bridge path — which includes the
|
|
38176
|
+
* typed "operation not yet observed on this tab" case (open the site's
|
|
38177
|
+
* page and retry).
|
|
38178
|
+
*/
|
|
38179
|
+
async graphqlQuery(opts) {
|
|
38180
|
+
if (!this.opts.capabilities.includes("graphql")) {
|
|
38181
|
+
throw new Error('FetchproxyServer.graphqlQuery(): MCP did not declare "graphql" in capabilities');
|
|
38182
|
+
}
|
|
38183
|
+
if (typeof opts.name !== "string" || opts.name.length === 0) {
|
|
38184
|
+
throw new Error("FetchproxyServer.graphqlQuery: opts.name must be a non-empty string");
|
|
38185
|
+
}
|
|
38186
|
+
const declaredNames = this.opts.graphqlOps.map((d) => d.name);
|
|
38187
|
+
if (!declaredNames.includes(opts.name)) {
|
|
38188
|
+
throw new Error(`FetchproxyServer.graphqlQuery: operation ${JSON.stringify(opts.name)} not in declared graphqlOps [${declaredNames.map((n) => JSON.stringify(n)).join(", ")}]`);
|
|
38189
|
+
}
|
|
38190
|
+
await this.ensureConnected();
|
|
38191
|
+
this.throwIfPendingPair();
|
|
38192
|
+
const id = this.nextRequestId++;
|
|
38193
|
+
const inner = {
|
|
38194
|
+
type: "request",
|
|
38195
|
+
id,
|
|
38196
|
+
op: "graphql_query",
|
|
38197
|
+
init: {
|
|
38198
|
+
name: opts.name,
|
|
38199
|
+
variables: opts.variables,
|
|
38200
|
+
...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
|
|
38201
|
+
}
|
|
38202
|
+
};
|
|
38203
|
+
const pending = new Promise((resolve, reject) => {
|
|
38204
|
+
this.pendingGraphql.set(id, { resolve, reject });
|
|
38205
|
+
});
|
|
38206
|
+
await this.sendInnerFrame(inner);
|
|
38207
|
+
return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
|
|
38208
|
+
}
|
|
38024
38209
|
assertScopeSubset(requested, declared, label) {
|
|
38025
38210
|
const undeclared = undeclaredKeys(requested, declared);
|
|
38026
38211
|
if (undeclared.length > 0) {
|
|
@@ -38158,6 +38343,20 @@ var FetchproxyServer = class {
|
|
|
38158
38343
|
}
|
|
38159
38344
|
return;
|
|
38160
38345
|
}
|
|
38346
|
+
const graphqlCb = this.pendingGraphql.get(inner.id);
|
|
38347
|
+
if (graphqlCb) {
|
|
38348
|
+
this.pendingGraphql.delete(inner.id);
|
|
38349
|
+
if (inner.ok) {
|
|
38350
|
+
if (inner.op === "graphql_query") {
|
|
38351
|
+
graphqlCb.resolve(inner.data);
|
|
38352
|
+
} else {
|
|
38353
|
+
graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
|
|
38354
|
+
}
|
|
38355
|
+
} else {
|
|
38356
|
+
graphqlCb.reject(new FetchproxyProtocolError(inner.error));
|
|
38357
|
+
}
|
|
38358
|
+
return;
|
|
38359
|
+
}
|
|
38161
38360
|
const cookiesCb = this.pendingReadCookies.get(inner.id);
|
|
38162
38361
|
if (cookiesCb) {
|
|
38163
38362
|
this.pendingReadCookies.delete(inner.id);
|
|
@@ -38209,6 +38408,9 @@ var FetchproxyServer = class {
|
|
|
38209
38408
|
for (const { reject } of this.pendingDownload.values())
|
|
38210
38409
|
reject(err);
|
|
38211
38410
|
this.pendingDownload.clear();
|
|
38411
|
+
for (const { reject } of this.pendingGraphql.values())
|
|
38412
|
+
reject(err);
|
|
38413
|
+
this.pendingGraphql.clear();
|
|
38212
38414
|
}
|
|
38213
38415
|
/**
|
|
38214
38416
|
* 0.5.2+: read the current pair-pending pair code from whichever handle
|
|
@@ -38262,7 +38464,20 @@ var FetchproxyServer = class {
|
|
|
38262
38464
|
|
|
38263
38465
|
// node_modules/@fetchproxy/bootstrap/dist/index.js
|
|
38264
38466
|
var defaultFactory = (opts) => new FetchproxyServer(opts);
|
|
38265
|
-
|
|
38467
|
+
function createSessionLifter(opts) {
|
|
38468
|
+
let inFlight = null;
|
|
38469
|
+
return () => {
|
|
38470
|
+
if (inFlight)
|
|
38471
|
+
return inFlight;
|
|
38472
|
+
const p = runOneLift(opts).finally(() => {
|
|
38473
|
+
if (inFlight === p)
|
|
38474
|
+
inFlight = null;
|
|
38475
|
+
});
|
|
38476
|
+
inFlight = p;
|
|
38477
|
+
return p;
|
|
38478
|
+
};
|
|
38479
|
+
}
|
|
38480
|
+
async function runOneLift(opts) {
|
|
38266
38481
|
const envVar = opts.serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "") + "_DISABLE_FETCHPROXY";
|
|
38267
38482
|
const envVal = process.env[envVar];
|
|
38268
38483
|
if (envVal !== void 0 && envVal !== "" && envVal !== "0" && envVal !== "false") {
|
|
@@ -38389,12 +38604,18 @@ async function bootstrap(opts) {
|
|
|
38389
38604
|
});
|
|
38390
38605
|
indexedDbBucket[`${d.database}/${d.store}`] = values;
|
|
38391
38606
|
}
|
|
38607
|
+
const absent = (declared, got) => declared.filter((k) => !(k in got));
|
|
38392
38608
|
return {
|
|
38393
38609
|
cookies,
|
|
38394
38610
|
localStorage,
|
|
38395
38611
|
sessionStorage,
|
|
38396
38612
|
capturedHeaders,
|
|
38397
|
-
indexedDb: indexedDbBucket
|
|
38613
|
+
indexedDb: indexedDbBucket,
|
|
38614
|
+
missing: {
|
|
38615
|
+
cookies: absent(opts.declare.cookies, cookies),
|
|
38616
|
+
localStorage: absent(opts.declare.localStorage, localStorage),
|
|
38617
|
+
sessionStorage: absent(opts.declare.sessionStorage, sessionStorage)
|
|
38618
|
+
}
|
|
38398
38619
|
};
|
|
38399
38620
|
} finally {
|
|
38400
38621
|
try {
|
|
@@ -38499,7 +38720,7 @@ function loadAccount(env = process.env) {
|
|
|
38499
38720
|
// package.json
|
|
38500
38721
|
var package_default = {
|
|
38501
38722
|
name: "canvas-parent-mcp",
|
|
38502
|
-
version: "1.2.
|
|
38723
|
+
version: "1.2.3",
|
|
38503
38724
|
mcpName: "io.github.chrischall/canvas-parent-mcp",
|
|
38504
38725
|
description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
|
|
38505
38726
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -38543,9 +38764,9 @@ var package_default = {
|
|
|
38543
38764
|
"test:watch": "vitest"
|
|
38544
38765
|
},
|
|
38545
38766
|
dependencies: {
|
|
38546
|
-
"@chrischall/mcp-utils": "^0.
|
|
38547
|
-
"@fetchproxy/bootstrap": "^1.
|
|
38548
|
-
"@fetchproxy/server": "^1.
|
|
38767
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
38768
|
+
"@fetchproxy/bootstrap": "^1.9.1",
|
|
38769
|
+
"@fetchproxy/server": "^1.7.0",
|
|
38549
38770
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38550
38771
|
dotenv: "^17.4.2",
|
|
38551
38772
|
zod: "^4.4.3"
|
|
@@ -38584,54 +38805,65 @@ async function resolveAuth() {
|
|
|
38584
38805
|
const declaredDomain = baseHost.endsWith(".instructure.com") ? "instructure.com" : baseHost;
|
|
38585
38806
|
const name = readEnv("CANVAS_NAME") ?? baseHost;
|
|
38586
38807
|
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
|
38587
|
-
|
|
38588
|
-
|
|
38589
|
-
|
|
38590
|
-
|
|
38591
|
-
|
|
38592
|
-
|
|
38593
|
-
|
|
38594
|
-
|
|
38595
|
-
|
|
38596
|
-
|
|
38597
|
-
|
|
38598
|
-
|
|
38599
|
-
|
|
38600
|
-
|
|
38601
|
-
|
|
38602
|
-
|
|
38603
|
-
|
|
38604
|
-
|
|
38605
|
-
|
|
38606
|
-
|
|
38607
|
-
|
|
38608
|
-
|
|
38609
|
-
|
|
38610
|
-
|
|
38611
|
-
|
|
38612
|
-
|
|
38613
|
-
|
|
38614
|
-
|
|
38615
|
-
|
|
38616
|
-
|
|
38617
|
-
source: "fetchproxy"
|
|
38618
|
-
};
|
|
38619
|
-
} catch (e) {
|
|
38620
|
-
if (classifyBridgeError(e) === "bridge_down") {
|
|
38621
|
-
const downErr = e;
|
|
38622
|
-
throw new Error(
|
|
38623
|
-
`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
|
|
38624
|
-
);
|
|
38808
|
+
const account = {
|
|
38809
|
+
mode: "session",
|
|
38810
|
+
name,
|
|
38811
|
+
baseUrl: cleanBaseUrl,
|
|
38812
|
+
username: "",
|
|
38813
|
+
password: ""
|
|
38814
|
+
};
|
|
38815
|
+
return {
|
|
38816
|
+
account,
|
|
38817
|
+
refresh: () => liftBrowserCookie(declaredDomain, baseHost),
|
|
38818
|
+
source: "fetchproxy"
|
|
38819
|
+
};
|
|
38820
|
+
}
|
|
38821
|
+
throw new Error(
|
|
38822
|
+
"Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (OAuth), or install the fetchproxy extension and sign into your Canvas instance (unset CANVAS_DISABLE_FETCHPROXY if it is set)."
|
|
38823
|
+
);
|
|
38824
|
+
}
|
|
38825
|
+
var liftersByDomain = /* @__PURE__ */ new Map();
|
|
38826
|
+
function lifterFor(declaredDomain) {
|
|
38827
|
+
let lift = liftersByDomain.get(declaredDomain);
|
|
38828
|
+
if (!lift) {
|
|
38829
|
+
lift = createSessionLifter({
|
|
38830
|
+
serverName: package_default.name,
|
|
38831
|
+
version: package_default.version,
|
|
38832
|
+
domains: [declaredDomain],
|
|
38833
|
+
declare: {
|
|
38834
|
+
cookies: ["canvas_session", "pseudonym_credentials"],
|
|
38835
|
+
localStorage: [],
|
|
38836
|
+
sessionStorage: [],
|
|
38837
|
+
captureHeaders: []
|
|
38625
38838
|
}
|
|
38626
|
-
|
|
38839
|
+
});
|
|
38840
|
+
liftersByDomain.set(declaredDomain, lift);
|
|
38841
|
+
}
|
|
38842
|
+
return lift;
|
|
38843
|
+
}
|
|
38844
|
+
async function liftBrowserCookie(declaredDomain, baseHost) {
|
|
38845
|
+
try {
|
|
38846
|
+
const session = await lifterFor(declaredDomain)();
|
|
38847
|
+
const canvasSession = session.cookies["canvas_session"];
|
|
38848
|
+
const pseudoCreds = session.cookies["pseudonym_credentials"];
|
|
38849
|
+
if (!canvasSession || !pseudoCreds) {
|
|
38850
|
+
throw new Error(
|
|
38851
|
+
`required cookies not found on ${baseHost}. Sign into your Canvas instance in the browser (with the fetchproxy extension installed) and retry.`
|
|
38852
|
+
);
|
|
38853
|
+
}
|
|
38854
|
+
return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
|
|
38855
|
+
} catch (e) {
|
|
38856
|
+
if (classifyBridgeError(e) === "bridge_down") {
|
|
38857
|
+
const downErr = e;
|
|
38627
38858
|
throw new Error(
|
|
38628
|
-
`Canvas auth:
|
|
38859
|
+
`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
|
|
38629
38860
|
);
|
|
38630
38861
|
}
|
|
38862
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
38863
|
+
throw new Error(
|
|
38864
|
+
`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, and fetchproxy lift failed: ${msg}`
|
|
38865
|
+
);
|
|
38631
38866
|
}
|
|
38632
|
-
throw new Error(
|
|
38633
|
-
"Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (OAuth), or install the fetchproxy extension and sign into your Canvas instance (unset CANVAS_DISABLE_FETCHPROXY if it is set)."
|
|
38634
|
-
);
|
|
38635
38867
|
}
|
|
38636
38868
|
|
|
38637
38869
|
// src/client.ts
|
|
@@ -38869,28 +39101,35 @@ var DEFAULT_MAX_PAGES = 50;
|
|
|
38869
39101
|
var CanvasClient = class {
|
|
38870
39102
|
account;
|
|
38871
39103
|
sessionLoginFn;
|
|
38872
|
-
|
|
39104
|
+
/**
|
|
39105
|
+
* fetchproxy lift. Called on the first login AND on every renewal, which is
|
|
39106
|
+
* what keeps the path alive once the browser cookie lapses.
|
|
39107
|
+
*/
|
|
39108
|
+
refreshSession;
|
|
38873
39109
|
auth;
|
|
38874
39110
|
/** Lazily-built shared refresh_token exchanger (oauth mode only). */
|
|
38875
39111
|
oauthRefresh = null;
|
|
38876
39112
|
/**
|
|
38877
|
-
* `
|
|
38878
|
-
* the
|
|
38879
|
-
*
|
|
38880
|
-
*
|
|
38881
|
-
*
|
|
38882
|
-
*
|
|
39113
|
+
* `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
|
|
39114
|
+
* `sessionLogin()` as the way this client mints a session cookie. It is
|
|
39115
|
+
* called lazily on the first request and again on every 401, so a lapsed
|
|
39116
|
+
* browser cookie recovers by re-reading the tab instead of dead-ending.
|
|
39117
|
+
*
|
|
39118
|
+
* It must re-read the browser each time rather than return a captured
|
|
39119
|
+
* value — that capture-once shape is precisely what made a 401 terminal
|
|
39120
|
+
* here, with a restart the only cure.
|
|
38883
39121
|
*/
|
|
38884
39122
|
constructor(account, opts = {}) {
|
|
38885
39123
|
this.account = account;
|
|
38886
39124
|
this.sessionLoginFn = opts.sessionLogin ?? sessionLogin;
|
|
38887
|
-
this.
|
|
39125
|
+
this.refreshSession = opts.refreshSession ?? null;
|
|
38888
39126
|
this.auth = new CookieSessionManager({
|
|
38889
39127
|
login: () => this.login(),
|
|
38890
|
-
// Reactive expiry: only a 401 in a mode that can re-mint warrants a
|
|
38891
|
-
// token mode
|
|
38892
|
-
//
|
|
38893
|
-
//
|
|
39128
|
+
// Reactive expiry: only a 401 in a mode that can re-mint warrants a
|
|
39129
|
+
// replay. token mode still can't (no refresh path), and a session
|
|
39130
|
+
// account with neither a lift nor credentials still can't — those 401s
|
|
39131
|
+
// fall through as a Response and `mapStatus` turns them into a
|
|
39132
|
+
// TokenExpiredError.
|
|
38894
39133
|
isExpired: (res) => res.status === 401 && this.canReauth()
|
|
38895
39134
|
});
|
|
38896
39135
|
}
|
|
@@ -38998,7 +39237,7 @@ var CanvasClient = class {
|
|
|
38998
39237
|
canReauth() {
|
|
38999
39238
|
const acct = this.account;
|
|
39000
39239
|
if (acct.mode === "oauth") return true;
|
|
39001
|
-
if (acct.mode === "session") return !!acct.username && !!acct.password;
|
|
39240
|
+
if (acct.mode === "session") return !!this.refreshSession || !!acct.username && !!acct.password;
|
|
39002
39241
|
return false;
|
|
39003
39242
|
}
|
|
39004
39243
|
/** Mint the auth credential for the current mode. Invoked by the manager. */
|
|
@@ -39008,8 +39247,8 @@ var CanvasClient = class {
|
|
|
39008
39247
|
return { headers: { Authorization: `Bearer ${acct.token}` } };
|
|
39009
39248
|
}
|
|
39010
39249
|
if (acct.mode === "session") {
|
|
39011
|
-
if (this.
|
|
39012
|
-
return { headers: { Cookie: this.
|
|
39250
|
+
if (this.refreshSession !== null) {
|
|
39251
|
+
return { headers: { Cookie: await this.refreshSession() } };
|
|
39013
39252
|
}
|
|
39014
39253
|
return { headers: { Cookie: await this.mintSessionCookie(acct) } };
|
|
39015
39254
|
}
|
|
@@ -39475,13 +39714,13 @@ try {
|
|
|
39475
39714
|
}
|
|
39476
39715
|
await runMcp({
|
|
39477
39716
|
name: "canvas",
|
|
39478
|
-
version: "1.2.
|
|
39717
|
+
version: "1.2.3",
|
|
39479
39718
|
// x-release-please-version
|
|
39480
39719
|
tools: [
|
|
39481
39720
|
(server) => {
|
|
39482
39721
|
if (resolved) {
|
|
39483
39722
|
const client = new CanvasClient(resolved.account, {
|
|
39484
|
-
|
|
39723
|
+
refreshSession: resolved.refresh
|
|
39485
39724
|
});
|
|
39486
39725
|
registerProfileTools(server, client);
|
|
39487
39726
|
registerObserveeTools(server, client);
|
package/dist/client.js
CHANGED
|
@@ -13,28 +13,35 @@ const DEFAULT_MAX_PAGES = 50;
|
|
|
13
13
|
export class CanvasClient {
|
|
14
14
|
account;
|
|
15
15
|
sessionLoginFn;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* fetchproxy lift. Called on the first login AND on every renewal, which is
|
|
18
|
+
* what keeps the path alive once the browser cookie lapses.
|
|
19
|
+
*/
|
|
20
|
+
refreshSession;
|
|
17
21
|
auth;
|
|
18
22
|
/** Lazily-built shared refresh_token exchanger (oauth mode only). */
|
|
19
23
|
oauthRefresh = null;
|
|
20
24
|
/**
|
|
21
|
-
* `
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
|
|
26
|
+
* `sessionLogin()` as the way this client mints a session cookie. It is
|
|
27
|
+
* called lazily on the first request and again on every 401, so a lapsed
|
|
28
|
+
* browser cookie recovers by re-reading the tab instead of dead-ending.
|
|
29
|
+
*
|
|
30
|
+
* It must re-read the browser each time rather than return a captured
|
|
31
|
+
* value — that capture-once shape is precisely what made a 401 terminal
|
|
32
|
+
* here, with a restart the only cure.
|
|
27
33
|
*/
|
|
28
34
|
constructor(account, opts = {}) {
|
|
29
35
|
this.account = account;
|
|
30
36
|
this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
|
|
31
|
-
this.
|
|
37
|
+
this.refreshSession = opts.refreshSession ?? null;
|
|
32
38
|
this.auth = new CookieSessionManager({
|
|
33
39
|
login: () => this.login(),
|
|
34
|
-
// Reactive expiry: only a 401 in a mode that can re-mint warrants a
|
|
35
|
-
// token mode
|
|
36
|
-
//
|
|
37
|
-
//
|
|
40
|
+
// Reactive expiry: only a 401 in a mode that can re-mint warrants a
|
|
41
|
+
// replay. token mode still can't (no refresh path), and a session
|
|
42
|
+
// account with neither a lift nor credentials still can't — those 401s
|
|
43
|
+
// fall through as a Response and `mapStatus` turns them into a
|
|
44
|
+
// TokenExpiredError.
|
|
38
45
|
isExpired: (res) => res.status === 401 && this.canReauth(),
|
|
39
46
|
});
|
|
40
47
|
}
|
|
@@ -157,11 +164,12 @@ export class CanvasClient {
|
|
|
157
164
|
const acct = this.account;
|
|
158
165
|
if (acct.mode === 'oauth')
|
|
159
166
|
return true;
|
|
160
|
-
// session:
|
|
161
|
-
//
|
|
162
|
-
//
|
|
167
|
+
// session: either a browser lift (fetchproxy) or real form credentials
|
|
168
|
+
// can re-mint. The lift is the fetchproxy path's re-auth — before it
|
|
169
|
+
// existed, that path synthesized empty username/password and a captured
|
|
170
|
+
// cookie, so its 401 was terminal and only a restart recovered.
|
|
163
171
|
if (acct.mode === 'session')
|
|
164
|
-
return !!acct.username && !!acct.password;
|
|
172
|
+
return !!this.refreshSession || (!!acct.username && !!acct.password);
|
|
165
173
|
return false; // token mode: no refresh path.
|
|
166
174
|
}
|
|
167
175
|
/** Mint the auth credential for the current mode. Invoked by the manager. */
|
|
@@ -171,11 +179,10 @@ export class CanvasClient {
|
|
|
171
179
|
return { headers: { Authorization: `Bearer ${acct.token}` } };
|
|
172
180
|
}
|
|
173
181
|
if (acct.mode === 'session') {
|
|
174
|
-
// fetchproxy path:
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return { headers: { Cookie: this.preloadedCookie } };
|
|
182
|
+
// fetchproxy path: re-read the browser instead of posting a form login.
|
|
183
|
+
// Runs on every mint, not just the first, so an expiry renews.
|
|
184
|
+
if (this.refreshSession !== null) {
|
|
185
|
+
return { headers: { Cookie: await this.refreshSession() } };
|
|
179
186
|
}
|
|
180
187
|
return { headers: { Cookie: await this.mintSessionCookie(acct) } };
|
|
181
188
|
}
|
package/dist/index.js
CHANGED
|
@@ -46,12 +46,12 @@ catch (e) {
|
|
|
46
46
|
// host's install-time `tools/list` succeeds.
|
|
47
47
|
await runMcp({
|
|
48
48
|
name: 'canvas',
|
|
49
|
-
version: '1.2.
|
|
49
|
+
version: '1.2.3', // x-release-please-version
|
|
50
50
|
tools: [
|
|
51
51
|
(server) => {
|
|
52
52
|
if (resolved) {
|
|
53
53
|
const client = new CanvasClient(resolved.account, {
|
|
54
|
-
|
|
54
|
+
refreshSession: resolved.refresh,
|
|
55
55
|
});
|
|
56
56
|
registerProfileTools(server, client);
|
|
57
57
|
registerObserveeTools(server, client);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-parent-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"mcpName": "io.github.chrischall/canvas-parent-mcp",
|
|
5
5
|
"description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"test:watch": "vitest"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@chrischall/mcp-utils": "^0.
|
|
48
|
-
"@fetchproxy/bootstrap": "^1.
|
|
49
|
-
"@fetchproxy/server": "^1.
|
|
47
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
48
|
+
"@fetchproxy/bootstrap": "^1.9.1",
|
|
49
|
+
"@fetchproxy/server": "^1.7.0",
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
51
|
"dotenv": "^17.4.2",
|
|
52
52
|
"zod": "^4.4.3"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/canvas-parent-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.2.
|
|
9
|
+
"version": "1.2.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "1.2.
|
|
14
|
+
"version": "1.2.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|