Skip to content

Commit 92efff0

Browse files
nesandersclaude
andauthored
Remote MCP server deployment (#2151)
* feat: add AI Research Tools page under Learn menu - New page at /learn/ai-tools explaining how to connect AI assistants (Claude Desktop, ChatGPT) to MAPLE via MCP - Covers: what MCP is, what users can ask, 4 example prompts, step-by-step setup with validated external links, privacy notes - NavbarLinkAiTools added to Learn dropdown (mobile + desktop) - navigation.aiTools and titles.ai_tools i18n keys added - How MAPLE Uses AI page links to the new guide Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add parallel embedding backfill script with exponential backoff Processes all collections concurrently (CONCURRENCY=8) and retries quota-exhausted requests with exponential backoff (up to 6 retries, 1s base, 2x multiplier + jitter) rather than failing permanently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add Dockerfile and Next.js proxy rewrite for MCP server Cloud Run deploy - mcp-server/Dockerfile: two-stage build (builder compiles TS, runtime installs prod deps only); listens on PORT=8080/HOST=0.0.0.0 for Cloud Run; uses Workload Identity (no credentials file) - mcp-server/.dockerignore: excludes tests, dev scripts, .env files - next.config.js: proxies /api/mcp → MCP_SERVER_URL/mcp when env var is set, keeping the public endpoint at mapletestimony.org/api/mcp Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add per-token rate limiting and Cloud Run deployment for dev - rateLimit.ts: 60 req/min + 1,000 req/day per token (in-memory, resets on daily UTC boundary); applied after auth middleware on POST /mcp - index-http.ts: wire in rateLimitMiddleware - Cloud Run service deployed to digital-testimony-dev (us-central1): max-instances=2, 512Mi, 30s timeout, Workload Identity service account - Artifact Registry repo maple-mcp created in us-central1 - Billing budget: $60/month (~$2/day) with 50%/100% alerts Vertex AI QPM quota reduction (200 QPM / ~$1/day) requires manual action in Cloud Console — CLI cannot reduce below service default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: implement Option B proxy — Next.js API route forwards to private Cloud Run - pages/api/mcp.ts: server-side proxy that fetches a Google identity token for Cloud Run IAM and forwards the user's MAPLE token in X-Maple-Authorization; Cloud Run URL never exposed to clients - mcp-server/auth.ts: check X-Maple-Authorization first (proxy path), fall back to Authorization (direct/local path) - next.config.js: remove dead MCP_SERVER_URL rewrite (replaced by API route) - mcp-server/create-agent-key.ts: fix stale /sse curl example → /mcp - IAM: roles/run.invoker granted to Compute Engine and App Engine default service accounts so Next.js Cloud Run can invoke the MCP service - google-auth-library added to main package.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: implement B1 — Firebase Function proxy for MCP server - functions/src/mcp/proxy.ts: mcpProxy onRequest function that fetches a Google identity token for Cloud Run IAM and forwards the user MAPLE token in X-Maple-Authorization; Cloud Run stays --no-allow-unauthenticated - functions/src/index.ts: export mcpProxy - firebase.json: add hosting rewrite /api/mcp → mcpProxy (us-central1) - Remove pages/api/mcp.ts — static export doesn't support API routes - google-auth-library added to functions/package.json - AiTools.tsx: add TODO to update connection config once proxy is live - roles/run.invoker granted to App Engine default SA (Firebase Functions) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: set MCP_SERVER_URL env var for mcpProxy function on dev Picked up automatically by Firebase CLI at deploy time via the .env.digital-testimony-dev file convention. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve full proxy chain for Firebase Function → Cloud Run MCP. Tested as working by connecting Claude to firebase function. - proxy.ts: use GCP metadata server for identity token (google-auth-library getRequestHeaders() returns Headers object, not plain object — bracket access returns undefined); forward Accept + MCP-Protocol-Version headers from client so MCP content negotiation works end-to-end - proxy.ts: remove google-auth-library dependency (metadata server is more reliable in GCP-hosted environments) - auth.ts: add X-Maple-Token header support (Firebase Functions strips Authorization from allUsers-accessible functions); precedence order: X-Maple-Authorization > X-Maple-Token > Authorization - AiTools.tsx: update config snippet to use X-Maple-Token header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove debug logging from mcpProxy function Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: polish AI Research Tools user guide - Remove resolved TODO comment - Fix Claude Desktop platform (Mac/Windows, not mobile) - Fix ChatGPT link to consumer-facing help article - Reframe token step in plain language for non-technical users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update lockfiles after google-auth-library install Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .gcloudignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: gitignore CLAUDE.md (local dev notes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update implementation plan with current architecture and auth flow diagram Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply Prettier formatting to implementation_plan.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR review comment - MapleAI.tsx: fix punctuation — one sentence, no period before "ask" - token.tsx: update config snippet to use correct URL and X-Maple-Token header - index-http.ts: guard DISABLE_AUTH so it only takes effect outside production Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove unused google-auth-library, restore yarn.lock registry google-auth-library was added but replaced by the GCP metadata server approach (which works correctly in Firebase Functions). Running npm install to add it had corrupted yarn.lock with registry.npmjs.org URLs instead of registry.yarnpkg.com. This restores the lockfile from upstream and regenerates with yarn install. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: extract hardcoded strings to i18n for AI pages - AiTools.tsx: fully converted to useTranslation("aiTools"); all text in new public/locales/en/aiTools.json - MapleAI.tsx: AI research section extracted to mapleAI.json "aiResearch" key - pages/learn/ai-tools.tsx: register "aiTools" namespace in getStaticProps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove google-auth-library from functions, restore functions/yarn.lock Same fix as 7b0f035 but for the functions sub-package. google-auth-library was added to functions/package.json but never used there either. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace Firebase Hosting MCP rewrite with Next.js/Vercel rewrite Firebase Hosting is no longer used. Replace with a next.config.js rewrite that Vercel executes at the edge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6f9efb7 commit 92efff0

22 files changed

Lines changed: 1027 additions & 154 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,5 @@ cert.txt
9292
# local MCP server config (contains auth tokens)
9393
.mcp.json
9494
mcp-server/create-agent-key.ts
95+
.gcloudignore
96+
CLAUDE.md

components/Navbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
NavbarLinkBallotQuestions,
1313
DESKTOP_NAV_ITEM_CLASS,
1414
NavbarLinkAI,
15+
NavbarLinkAiTools,
1516
NavbarLinkBills,
1617
NavbarLinkHearings,
1718
NavbarLinkEditProfile,
@@ -92,6 +93,7 @@ const MobileNav: React.FC<React.PropsWithChildren<unknown>> = () => {
9293
<NavbarLinkEffective handleClick={closeNav} />
9394
<NavbarLinkProcess handleClick={closeNav} />
9495
<NavbarLinkWhyUse handleClick={closeNav} />
96+
<NavbarLinkAiTools handleClick={closeNav} />
9597
</NavDropdown>
9698
</Nav>
9799
)
@@ -270,6 +272,7 @@ const DesktopNav: React.FC<React.PropsWithChildren<unknown>> = () => {
270272
<NavbarLinkEffective />
271273
<NavbarLinkProcess />
272274
<NavbarLinkWhyUse />
275+
<NavbarLinkAiTools />
273276
</Dropdown.Menu>
274277
</Dropdown>
275278
</div>

components/NavbarComponents.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,26 @@ export const Avatar = () => {
8181
)
8282
}
8383

84+
export const NavbarLinkAiTools: React.FC<
85+
React.PropsWithChildren<{
86+
handleClick?: any
87+
other?: any
88+
}>
89+
> = ({ handleClick, other }) => {
90+
const isMobile = useMediaQuery("(max-width: 768px)")
91+
const { t } = useTranslation(["common", "auth"])
92+
return (
93+
<NavbarDropdownLink
94+
className={isMobile ? "navLink-primary" : ""}
95+
href="/learn/ai-tools"
96+
handleClick={handleClick}
97+
other={other}
98+
>
99+
{t("navigation.aiTools")}
100+
</NavbarDropdownLink>
101+
)
102+
}
103+
84104
export const NavbarLinkAI: React.FC<
85105
React.PropsWithChildren<{
86106
handleClick?: any

components/about/MapleAI/MapleAI.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useTranslation } from "next-i18next"
22
import { Container, Row, Col } from "../../bootstrap"
3+
import { Internal } from "../../links"
34
import {
45
MemberItem,
56
Divider,
@@ -184,6 +185,21 @@ const MapleAI = () => {
184185
</SectionContainer>
185186
</Col>
186187
</Row>
188+
<Row>
189+
<Col className="py-3">
190+
<SectionContainer>
191+
<SectionTitle className="p-2">{t("aiResearch.title")}</SectionTitle>
192+
<DescrContainer className="py-3 px-4">
193+
{t("aiResearch.desc")}
194+
</DescrContainer>
195+
<DescrContainer className="pb-3 px-4">
196+
<Internal href="/learn/ai-tools">
197+
{t("aiResearch.linkText")}
198+
</Internal>
199+
</DescrContainer>
200+
</SectionContainer>
201+
</Col>
202+
</Row>
187203
</Container>
188204
)
189205
}
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { useTranslation } from "next-i18next"
2+
import styled from "styled-components"
3+
import { Container, Row, Col } from "../../bootstrap"
4+
import {
5+
DescrContainer,
6+
Divider,
7+
PageDescr,
8+
PageTitle,
9+
SectionContainer,
10+
SectionTitle
11+
} from "../../shared/CommonComponents"
12+
import { Internal } from "../../links"
13+
14+
const ExampleBox = styled.div`
15+
background: var(--maple-surface-raised);
16+
border-left: 4px solid var(--maple-brand-primary);
17+
border-radius: 0 var(--maple-radius-md) var(--maple-radius-md) 0;
18+
padding: 1rem 1.25rem;
19+
margin: 0.75rem 0;
20+
font-size: 15px;
21+
font-style: italic;
22+
color: var(--maple-text-body);
23+
`
24+
25+
const StepNumber = styled.div`
26+
display: flex;
27+
align-items: center;
28+
justify-content: center;
29+
width: 2rem;
30+
height: 2rem;
31+
border-radius: 50%;
32+
background: var(--maple-brand-primary);
33+
color: white;
34+
font-weight: 700;
35+
font-size: 1rem;
36+
flex-shrink: 0;
37+
margin-right: 0.75rem;
38+
`
39+
40+
const StepRow = styled.div`
41+
display: flex;
42+
align-items: flex-start;
43+
padding: 0.75rem 1.25rem;
44+
`
45+
46+
const StepText = styled.div`
47+
font-size: 16px;
48+
font-weight: 500;
49+
color: var(--maple-text-body);
50+
line-height: 1.5;
51+
`
52+
53+
const ExampleLabel = styled.div`
54+
font-size: 13px;
55+
font-weight: 700;
56+
text-transform: uppercase;
57+
letter-spacing: 0.06em;
58+
color: var(--maple-brand-primary);
59+
margin-bottom: 0.25rem;
60+
`
61+
62+
export const AiTools = () => {
63+
const { t } = useTranslation("aiTools")
64+
65+
return (
66+
<Container>
67+
<Row>
68+
<Col>
69+
<PageTitle>{t("title")}</PageTitle>
70+
</Col>
71+
</Row>
72+
<Row>
73+
<Col className="py-3">
74+
<PageDescr>{t("description")}</PageDescr>
75+
</Col>
76+
</Row>
77+
78+
{/* What is it */}
79+
<Row>
80+
<Col className="py-3">
81+
<SectionContainer>
82+
<SectionTitle className="p-2">{t("section1.title")}</SectionTitle>
83+
<DescrContainer className="py-3 px-4">
84+
{t("section1.desc1")}
85+
</DescrContainer>
86+
<DescrContainer className="pb-3 px-4">
87+
{t("section1.desc2Pre")}{" "}
88+
<b>
89+
<a
90+
href="https://modelcontextprotocol.io/introduction"
91+
target="_blank"
92+
rel="noreferrer"
93+
>
94+
{t("section1.desc2LinkText")}
95+
</a>
96+
</b>
97+
{t("section1.desc2Post")}
98+
</DescrContainer>
99+
</SectionContainer>
100+
</Col>
101+
</Row>
102+
103+
{/* What you can do */}
104+
<Row>
105+
<Col className="py-3">
106+
<SectionContainer>
107+
<SectionTitle className="p-2">{t("section2.title")}</SectionTitle>
108+
<DescrContainer className="py-3 px-4">
109+
{t("section2.intro")}
110+
</DescrContainer>
111+
<DescrContainer className="pb-1 px-4">
112+
<ul>
113+
<li className="pb-3">
114+
<b>{t("section2.item1Bold")}</b> {t("section2.item1Main")}
115+
</li>
116+
<li className="pb-3">
117+
<b>{t("section2.item2Bold")}</b> {t("section2.item2Main")}
118+
</li>
119+
<li className="pb-3">
120+
<b>{t("section2.item3Bold")}</b> {t("section2.item3Main")}
121+
</li>
122+
<li className="pb-3">
123+
<b>{t("section2.item4Bold")}</b> {t("section2.item4Main")}
124+
</li>
125+
<li className="pb-3">
126+
<b>{t("section2.item5Bold")}</b> {t("section2.item5Main")}
127+
</li>
128+
<li className="pb-3">
129+
<b>{t("section2.item6Bold")}</b> {t("section2.item6Main")}
130+
</li>
131+
<li>
132+
<b>{t("section2.item7Bold")}</b> {t("section2.item7Main")}
133+
</li>
134+
</ul>
135+
</DescrContainer>
136+
</SectionContainer>
137+
</Col>
138+
</Row>
139+
140+
{/* Examples */}
141+
<Row>
142+
<Col className="py-3">
143+
<SectionContainer>
144+
<SectionTitle className="p-2">{t("section3.title")}</SectionTitle>
145+
<DescrContainer className="py-3 px-4">
146+
{t("section3.intro")}
147+
</DescrContainer>
148+
<div className="px-4 pb-4">
149+
<ExampleLabel>{t("section3.example1Label")}</ExampleLabel>
150+
<ExampleBox>
151+
&ldquo;{t("section3.example1Text")}&rdquo;
152+
</ExampleBox>
153+
154+
<ExampleLabel>{t("section3.example2Label")}</ExampleLabel>
155+
<ExampleBox>
156+
&ldquo;{t("section3.example2Text")}&rdquo;
157+
</ExampleBox>
158+
159+
<ExampleLabel>{t("section3.example3Label")}</ExampleLabel>
160+
<ExampleBox>
161+
&ldquo;{t("section3.example3Text")}&rdquo;
162+
</ExampleBox>
163+
164+
<ExampleLabel>{t("section3.example4Label")}</ExampleLabel>
165+
<ExampleBox>
166+
&ldquo;{t("section3.example4Text")}&rdquo;
167+
</ExampleBox>
168+
</div>
169+
</SectionContainer>
170+
</Col>
171+
</Row>
172+
173+
{/* How to get started */}
174+
<Row>
175+
<Col className="py-3">
176+
<SectionContainer>
177+
<SectionTitle className="p-2">{t("section4.title")}</SectionTitle>
178+
<DescrContainer className="py-3 px-4">
179+
{t("section4.intro")}
180+
</DescrContainer>
181+
<StepRow>
182+
<StepNumber>1</StepNumber>
183+
<StepText>
184+
<b>{t("section4.step1Bold")}</b> {t("section4.step1Pre")}{" "}
185+
<Internal href="/login">{t("section4.step1LinkText")}</Internal>
186+
{t("section4.step1Post")}
187+
</StepText>
188+
</StepRow>
189+
<Divider />
190+
<StepRow>
191+
<StepNumber>2</StepNumber>
192+
<StepText>
193+
<b>{t("section4.step2Bold")}</b> {t("section4.step2Intro")}
194+
<ul style={{ marginTop: "0.5rem" }}>
195+
<li className="pb-2">
196+
<b>{t("section4.step2item1Bold")}</b>{" "}
197+
{t("section4.step2item1Tag")} {t("section4.step2item1Pre")}{" "}
198+
<a
199+
href="https://claude.ai/download"
200+
target="_blank"
201+
rel="noreferrer"
202+
>
203+
{t("section4.step2item1Link1")}
204+
</a>
205+
{t("section4.step2item1Mid")}{" "}
206+
<a
207+
href="https://modelcontextprotocol.io/quickstart/user"
208+
target="_blank"
209+
rel="noreferrer"
210+
>
211+
{t("section4.step2item1Link2")}
212+
</a>
213+
{t("section4.step2item1Post")}
214+
</li>
215+
<li className="pb-2">
216+
<b>{t("section4.step2item2Bold")}</b>{" "}
217+
{t("section4.step2item2Tag")} {t("section4.step2item2Pre")}{" "}
218+
<a
219+
href="https://help.openai.com/en/articles/11487775-connectors-in-chatgpt"
220+
target="_blank"
221+
rel="noreferrer"
222+
>
223+
{t("section4.step2item2LinkText")}
224+
</a>{" "}
225+
{t("section4.step2item2Post")}
226+
</li>
227+
<li>
228+
<b>{t("section4.step2item3Bold")}</b>{" "}
229+
{t("section4.step2item3Main")}
230+
</li>
231+
</ul>
232+
</StepText>
233+
</StepRow>
234+
<Divider />
235+
<StepRow>
236+
<StepNumber>3</StepNumber>
237+
<StepText>
238+
<b>{t("section4.step3Bold")}</b> {t("section4.step3Pre")}{" "}
239+
<Internal href="/dev/token">
240+
{t("section4.step3LinkText")}
241+
</Internal>{" "}
242+
{t("section4.step3Post")}
243+
</StepText>
244+
</StepRow>
245+
<Divider />
246+
<StepRow>
247+
<StepNumber>4</StepNumber>
248+
<StepText>
249+
<b>{t("section4.step4Bold")}</b> {t("section4.step4Pre")}{" "}
250+
<code>YOUR_TOKEN_HERE</code> {t("section4.step4Post")}
251+
</StepText>
252+
</StepRow>
253+
<div className="px-4 pb-4">
254+
<pre
255+
style={{
256+
background: "var(--maple-surface-raised)",
257+
borderRadius: "var(--maple-radius-md)",
258+
padding: "1rem",
259+
fontSize: "13px",
260+
overflowX: "auto"
261+
}}
262+
>{`{
263+
"mcpServers": {
264+
"maple": {
265+
"type": "http",
266+
"url": "https://mapletestimony.org/api/mcp",
267+
"headers": {
268+
"X-Maple-Token": "Bearer YOUR_TOKEN_HERE"
269+
}
270+
}
271+
}
272+
}`}</pre>
273+
</div>
274+
<Divider />
275+
<StepRow>
276+
<StepNumber>5</StepNumber>
277+
<StepText>
278+
<b>{t("section4.step5Bold")}</b> {t("section4.step5Post")}
279+
</StepText>
280+
</StepRow>
281+
<DescrContainer className="py-3 px-4">
282+
<b>{t("section4.needHelp")}</b> {t("section4.needHelpPost")}{" "}
283+
<a href="mailto:info@mapletestimony.org">
284+
info@mapletestimony.org
285+
</a>
286+
.
287+
</DescrContainer>
288+
</SectionContainer>
289+
</Col>
290+
</Row>
291+
292+
{/* Privacy */}
293+
<Row>
294+
<Col className="py-3">
295+
<SectionContainer>
296+
<SectionTitle className="p-2">{t("section5.title")}</SectionTitle>
297+
<DescrContainer className="py-3 px-4">
298+
{t("section5.desc1")}
299+
</DescrContainer>
300+
<DescrContainer className="pb-3 px-4">
301+
{t("section5.desc2Pre")}{" "}
302+
<Internal href="/about/how-maple-uses-ai">
303+
{t("section5.desc2LinkText")}
304+
</Internal>
305+
</DescrContainer>
306+
</SectionContainer>
307+
</Col>
308+
</Row>
309+
</Container>
310+
)
311+
}
312+
313+
export default AiTools

0 commit comments

Comments
 (0)