Portcall
Provides an MCP endpoint for an Obsidian vault via the mcpvault plugin, allowing MCP clients to access and interact with the vault's contents.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Portcallsearch my vault for meeting notes"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Portcall
A small plugin gateway that serves local MCP servers over HTTP.
The name is a nautical pun: port call — port (harbour / network port) + call
(a ship's stop / a request).
What it is
Portcall listens on one HTTP port and mounts one or more MCP servers at separate paths:
/vault/mcp → mcpvault (Obsidian vault)
/healthz → liveness + mount listEach mount is an independent MCP endpoint. Clients register them separately — there is no tool aggregation, so no name collisions and no namespacing scheme to maintain.
Exposing the port beyond localhost is deliberately out of scope. Put a tunnel,
reverse proxy, or nothing at all in front of it; Portcall binds to 127.0.0.1
by default and does not care what is upstream.
Related MCP server: mcp-unify
Why not a stdio bridge
The obvious way to put a stdio MCP server on HTTP is a generic bridge such as
supergateway. That works, but it has a structural problem: every request or
session spawns a child process, and reaping those children is easy to get wrong.
In supergateway specifically, the child is only reclaimed from
transport.onclose or transport.onerror. Nothing calls transport.close() on
a normally-completed stateless request, so onclose never fires and every
successful request leaks a process — only failed requests get cleaned up. Its
stateful mode does not leak (a session timer closes the transport), but it holds
a long-lived GET SSE stream instead, which some proxies handle badly.
Wrapping the command in npx makes it worse: npx forks the real server, so
killing the child kills the wrapper and orphans the grandchild.
Portcall's answer is to not spawn anything when it does not have to.
Adapters
Adapter | For | How |
| Servers that export a factory as a library | Calls the factory in-process. No child process exists, so there is nothing to reap. |
| Third-party servers that only speak stdio | Not implemented yet. When it lands it must reclaim the child on normal completion, not just on error, and handle process-group kills for wrapper commands. |
inProcess is the interesting case and covers the servers worth self-hosting.
@bitbonsai/mcpvault, for example, exports createServer(vaultPath, options)
returning an MCP SDK v2 Server; its bin entry is essentially
serveStdio(() => createServer(...)). Portcall calls the same function directly
and skips stdio entirely.
The SDK builds a fresh server instance per request and disposes it with the request, so there is no session state to time out and no accumulating handles.
Abuse control
A client that fails authentication PORTCALL_GUARD_FAILURES times inside the
window is blocked for PORTCALL_GUARD_BLOCK_MS, on every path, whatever token
it presents afterwards. A separate cap refuses more than PORTCALL_GUARD_RATE
requests per client per window. Rejections always reach the log, with the
client and — when a block trips — how long it lasts.
This belongs here rather than at the CDN because only this process knows whether a token was right. Five wrong tokens is a far sharper signal than five hundred requests, and an edge rate limit cannot tell them apart. An edge limit is still the better answer to a volumetric flood, which this cannot help with: by the time the request is counted here it has already crossed the link. The two do not overlap.
Clients are told apart by cf-connecting-ip, which is trustworthy only
because the listener is bound to loopback — nothing but the local tunnel can
deliver a request, so nothing else can forge the header. Bound to a public
interface the header is ignored and the socket address is used instead;
believing it there would let one caller be blocked under another's address.
State is in memory and dies with the process, so a restart forgives everyone. That is deliberate: the worst outcome for a personal gateway is locking out its owner.
Plugins
Mount | Serves |
|
|
| Korea Investment & Securities overseas-stock quotations, read-only |
Vault images
mcpvault reads and writes notes but has nothing for attachments, so an
embedded screenshot arrives as the literal text ![[shot.png]] and the image
itself is unreachable. Four tools fill that in:
read_imagereturns an attachment as an image, so it can actually be looked at. It takes a vault-relative path or the bare filename an embed uses, and resolves the latter the way Obsidian does — an ambiguous name is reported with its candidates rather than guessed at. The file in the vault is never touched: resizing and conversion happen on a copy in a temporary directory. SVG comes back as its source, being markup.find_imageslists attachments with their size, dimensions and what uses them, and filters by name, folder, orphans (nothing links to it) or broken embeds (no file behind the link). Dimensions are read from the file header rather than by shelling out per file.write_imagesaves an image from base64, a URL or a local file, and can append the Obsidian embed to a note.delete_imageremoves an attachment, for clearing out the orphansfind_imagesturns up.
These are merged into mcpvault's tool listing rather than mounted separately,
so the vault stays one connector. mcpvault registers tools/list and
tools/call on a low-level Server, which leaves no seam to register extra
tools into, so merge.ts fronts it over a linked in-memory transport and
routes each call to whichever side owns the name.
What counts as using an image
An image is in use if a note embeds it, if a note names it in frontmatter as
a cover or a banner, or if a canvas puts it on a board. Only the first can
report a link as broken: frontmatter is read by pattern rather than parsed
as YAML, so an over-eager token there would invent a missing file. Reading it
loosely is still worth it in the other direction, where the cost of missing a
reference is calling a file an orphan when it is not — and delete_image
refuses an image anything still uses.
Returning an image
Re-encoding is done with sips, which ships with macOS: the daemon is a
personal service on one Mac, so that is one less native dependency to build
and keep current. An image already within the pixel and byte budget is sent
untouched, which also means an animated GIF still animates.
When re-encoding is unavoidable, the source format decides the target, because it is the best available hint about the content. Measured on a 4032×3024 photo and a 3000×2000 screenshot:
Source | Sent as | Keeping it | Switching |
JPEG photo | JPEG | 1,056 kB | 2,930 kB as PNG |
HEIC photo | JPEG | 1,086 kB | 3,005 kB as PNG |
PNG screenshot | PNG | 33 kB | 75 kB as JPEG |
So JPEG, HEIC and AVIF are re-encoded as JPEG; PNG, GIF, TIFF, BMP and ICO as
PNG. sips cannot write WebP, so a WebP that has to be re-encoded becomes
PNG, with JPEG behind it because a WebP is as likely to be photographic. If
the result still will not fit, the longest edge is halved, twice.
Fencing in the writes
Every path is resolved against the vault root and anything that climbs out is refused.
The bytes are sniffed and must be a real image that agrees with the destination extension. A name is not evidence.
An existing file is never replaced unless
overwriteis passed, anddelete_imageneeds the path repeated inconfirmPath.A URL import is http(s) only and must answer with an
image/*type. Redirects are followed by hand, up to four, so that every hop is checked against private addresses — a public host that redirects to a private one gets no further than the check. The daemon is reachable from the internet through a tunnel, and this stops it being used to reach into the network it sits in.fetchresolves again after each check, so this narrows the hole rather than closing it.Copying from a local path is off until
PORTCALL_VAULT_IMPORT_DIRSnames the folders it may read. The mount otherwise touches nothing outside the vault, and that is worth keeping deliberate.
A read-only mount serves read_image and find_images, and withholds
write_image and delete_image.
Vault history
The vault is a git repository with a job snapshotting it on a timer, and mcpvault only ever sees the working tree. Four tools read what is behind it:
vault_changessummarises a window: one row per note, with how much was added and removed and when it was last touched, newest first. Attachments and Obsidian's own files —.obsidian/,.trash/, a dotfile at the root — are counted in their own buckets rather than among the notes. They are still reported: someone who toggled a plugin should not be told nothing happened. What is and is not vault content is decided bypaths.ts, the same list the image tools walk with, so the two cannot come to disagree about it.note_historylists the snapshots that touched one note, follows it through renames, and finds notes that are no longer in the vault.note_diffreturns the lines that changed between two points.note_atreturns a note as it stood. This is also how one is recovered — read it here, write it back with mcpvault'swrite_note— which is why there is no restore tool. A recovery is then an ordinary edit, visible in the next snapshot like any other.
A vault that is not a git repository gets none of these, the way the KIS mount serves quotations only until an account is configured. A listed tool that always fails is worse than one that was never listed.
Why a window is not a list of commits
Snapshots are taken on a timer, so a commit boundary is where the clock fell
rather than where a thought ended. One sitting is scattered across several
commits, each titled snapshot <time>, and listing them shows the same note
four times while saying nothing about it. vault_changes compares the two
ends of the window instead, and note_history is what answers at commit
resolution — which is the question a commit boundary is actually good for
(when did that paragraph go).
Three things that would otherwise be quietly wrong
A Korean path comes back escaped. By default git prints anything outside ASCII as
"\355\225\234...", which then matches no file in the vault. Every call setscore.quotePath=false.git does not reject a date it cannot read.
--before=<value>falls back to the current time, so a typo would be answered with nothing changed rather than with the typo — the worst shape an error can take. The value goes throughrev-parse --sincefirst and one that comes back as now is refused, which is what happens to지난주oryesterdya. What cannot be caught is a near miss git half-understands:last tusedayquietly becomes a real date. So the moment a date resolved to is reported in the answer, where a wrong reading is at least visible.A note renamed after the window closed. Inside that window the note only ever had its old name, so asking by the name it has today would report no change across its entire earlier life — and disagree with
note_history, which does follow renames. The diff is taken against every name the note has had.
GIT_OPTIONAL_LOCKS=0 is set on every call so that a read never takes the
index lock: the snapshot job commits on a timer, and a model asking what
changed should not be why that fails.
A rename combined with a heavy rewrite falls below git's 50% similarity threshold and reads as a delete and an add. That is left at git's default rather than loosened — a vault of notes started from the same template would otherwise start reporting renames between unrelated ones.
KIS
Quotation tools, which need no account: overseas_quote,
overseas_quote_detail, overseas_daily_prices, overseas_orderbook (ten
levels a side, with the session summary), overseas_history (long cached
series as CSV), fx_rate (exchange rate history for 14 currencies against
the dollar), overseas_index (Dow, Nasdaq Composite, Nasdaq 100, S&P 500)
and overseas_dividends.
Account tools, registered only when KIS_ACCOUNT is set: overseas_balance
(the whole account in one call — every position, cash and margin per
currency, and totals including withdrawable cash), overseas_holdings
(one exchange at a time, for its per-exchange totals), overseas_executions
(order and fill history, this year by default) and overseas_realized_pnl
(realised gains per disposal with FX rates). Prices are public and a portfolio
is not, so the two are opt-in separately.
Dividends
Two calls answer different halves of the question and neither answers both.
The rights calendar (period-rights) has the per-share amount but no payment
date; ICE's rights summary (rights-by-ice) has the ex-dividend and payment
dates but no amount. overseas_dividends asks both and joins them on the
record date the market itself keeps, leaving the dates off rather than
guessing when they do not line up. Events with no amount yet are returned
separately: those are the dividends declared but not paid.
The two calls do not take the same window. The amounts are filtered by record date, the calendar by announcement date, and the gap between the two is neither small nor steady — SGOV announces three weeks to two months ahead, and TQQQ published four quarterly record dates, one of them eleven months out, in a single announcement. Asked with no range the calendar answers ±3 months of announcements, which joined one of twelve SGOV dividends; asked for a year of lookback it joined all twelve. The end of the range needs no such margin, because every event measured was announced on or before its record date. The calendar has no continuation cursor and stopped at 50 rows for a ten-year range, so a long window on a monthly payer will silently lose its oldest rows.
What comes back is not a clean feed. ICE files splits, mergers and ticker
changes in the same list, so they are filtered out of an answer about
dividends. Some rows carry dates known only to the month, written with 00
for the day — 20260100 — alongside an empty record date; those are
placeholders, and parsed as dates they are either invalid or quietly the
wrong month, so they are marked approximate rather than dropped. A dividend
expected in December is worth knowing about; a fake date for it is not. Once
that month has passed the real schedule has been published, and the
placeholder is a superseded draft of a dividend already listed — so it is
dropped rather than reported as still upcoming.
The two feeds also disagree about what happened. ICE reports SOXL dividends
in March and June that the rights list returns nothing for, so a calendar
event inside the window with no amount against it is reported as unpriced
rather than left out. Dropping those would quietly understate a year's
income, which is the error this tool exists to correct.
The per-share figure is alct_frcr_unpr, 배정외화단가. KIS also sends
stkp_dvdn_frcr_amt2/3/4, named 주당배당외화금액, and those read
0.00000 on every cash dividend measured — a year of SGOV distributions,
plus TQQQ and SOXL — while their currencies crcy_cd2/3/4 come back
empty. Mapping the documented name would have reported every dividend as
zero, so the amount is the one that was checked against the live API and the
others are passed through under KIS's own names.
What it cannot do is say what was actually received. No KIS read API reports
a dividend as it lands in the account: the account calls carry no dividend
field at all, and the domestic 기간별계좌권리현황조회 (CTRGA011R), which
does carry allotted amount and tax withheld, returns nothing for an overseas
holding. So the tool reports what was declared per share, multiplies by no
position — a past dividend against a present holding is a plausible wrong
number — and the withholding a payment arrives net of is not available.
There is no capital gains tax tool because KIS exposes no tax API.
overseas_realized_pnl returns the disposal-level record a filing is built
from; the filing figures themselves come from KIS's own year-end statement.
Read-only is enforced three ways. No ordering tool is implemented, so none can
be called; the client refuses any path outside the allowlist; and within a
trading namespace — where account inquiries sit alongside the order endpoints
— it refuses any tr_id that is not an inquiry. KIS ends inquiry tr_ids with
R and orders with U (TTTS3012R reads a balance, TTTT1002U buys). All
three are covered by tests.
There is one trading namespace per market, and the guard holds a list rather than the overseas one alone. Only overseas endpoints are mounted today, but a domestic account inquiry added against a single-namespace guard would take the tr_id check out with it and be waved through on the allowlist alone — the guard going quiet rather than failing.
The access token needs care rather than cleverness. KIS issues one valid for 24
hours but refuses a re-issue within a minute of the last (EGW00133), so the
store lives for the process rather than the request — inProcess builds a
fresh server per request — mirrors the token to ~/.cache/portcall/ so a
restart does not spend an issue, and collapses concurrent cold-start callers
into a single request.
Account queries are paged: KIS signals more rows with tr_cont of F or M
and expects the next request to echo the cursor from the previous body. The
client walks that automatically, up to a page ceiling.
The cursor's parameter name is not uniform — most account inquiries take
CTX_AREA_FK200, the daily ledger takes 100, the rights calendar 50 — so
each endpoint declares its own and one that does not page declares that
instead. Sending the wrong width is not rejected: KIS drops parameters it
does not recognise and replays the first page, which ends the walk on the
repeated-cursor guard with the rest of the rows never fetched. Paging an
endpoint that returns a single page is refused outright for the same reason,
since handing back one page as though it were all of them is the failure that
cannot be seen from the outside.
overseas_history exists because backtesting wants more bars than a quote
tool should hand back. Nineteen years of daily bars is 4,801 of them, which
is forty-nine round trips at a hundred a page — tolerable once, absurd on
every request, and the answer never changes: a bar from 2014 is settled. So
the series is assembled once into ~/.cache/portcall/history/, and after
that only its tail is refreshed.
Three things follow from that, and each is a thing that would otherwise be quietly wrong:
A split rewrites history. Adjusted prices are restated all the way back, and the leveraged ETFs this exists for split often. Every request re-reads the newest page anyway; comparing it against what is stored turns that call into the staleness check, and a disagreement throws the series away rather than serving a wrong one.
A long gap leaves a hole. Unused for more than a hundred sessions and the newest page no longer touches what is stored. The walk closes the gap before the two halves are joined, so the series is never discontinuous.
The wrong venue looks like no data. SOXL lists on
AMSwhile SOXX, TQQQ and QQQM list onNAS, and asking the wrong one returns an empty page rather than an error.exchangecan therefore be left out: the US venues are tried in turn and the answer is remembered.
Bars come back as CSV — date,open,high,low,close,volume, oldest first —
rather than as a JSON object per bar. The quote tools return fourteen fields
including a bid/ask snapshot that means nothing on a daily bar; dropping the
nine a price series cannot use takes a bar from 284 bytes to 65. Nineteen
years is 231 kB instead of 1.3 MB. Weekly is 48 kB and monthly 11 kB, so a
whole span costs almost nothing at those sizes. A call is capped at 1,200
bars by default and says when it clipped.
History begins 2007-08-20 on this endpoint whatever the listing date — checked against several symbols, including ones listed decades earlier.
The chart endpoint behind fx_rate and overseas_index has no such cursor,
and caps a call at 100 rows without saying so: a request for three years of
daily bars comes back as the most recent hundred sessions, carrying the dates
that were asked for. Read as-is that says the series begins in April. Both
tools therefore report covered — the span the rows actually span — and set
truncated when a full page stopped short of the requested start. A short
page is KIS having nothing more, not KIS holding back, so only a full one is
flagged.
The won/foreign-currency flag is not consistent across KIS endpoints: the
consolidated balance reads 01 as won, while the realised P&L endpoint reads
02 as won. Both were confirmed against the live API rather than the
published parameter tables, and the tools present a single report option so
the inconsistency stops at this boundary.
Exchange rates are not all quoted the same way round: most pairs are units
per dollar, but EUR, GBP and AUD are dollars per unit, so fx_rate states the
direction in quotedAs rather than leaving it to be inferred.
Values come back as strings, exactly as KIS sends them; decimals says how
many places the venue quotes to. Quotes are delayed unless the account carries
a real-time subscription. Note also that the quotation and account APIs use
different exchange codes — NAS quotes Nasdaq, NASD covers the whole US
market on an account query — so the tools keep the two sets apart.
Protocol versions
Portcall is built on @modelcontextprotocol/server v2, which serves two
protocol eras from a single handler:
Modern (
2026-07-28) — per-request envelope. Requests carryMCP-Protocol-Version,Mcp-Method, and (for tool calls)Mcp-Nameheaders plus aparams._metablock. There is noinitializehandshake and no long-lived session; discovery isserver/discover.Legacy (2025-era) — served statelessly by default.
GETandDELETE(2025 session operations) answer405. SetPORTCALL_MODERN_ONLY=trueto reject legacy traffic outright.
Because the modern era is per-request, there is no standing SSE stream to keep
open. That sidesteps a class of proxy problem: some reverse proxies withhold
response headers until the first body byte arrives, which stalls a
just-opened-but-silent SSE stream indefinitely. For the streams that do occur,
PORTCALL_KEEPALIVE_MS controls the SSE comment-frame interval; lower it if a
proxy in front is buffering.
Configuration
All host-specific values come from the environment.
Variable | Default | Meaning |
| (required) | Absolute path to the Obsidian vault to serve |
| (unset) | Colon-separated folders |
|
| TCP port |
|
| Bind interface |
| (unset) | Static bearer token. Unset means no authentication |
| (unset) | Also mount the named plugin at |
| (unset) | Serve every mount under |
|
| SSE keepalive interval; |
|
| Reject 2025-era requests instead of serving them |
|
| Auth failures from one client before it is blocked; |
|
| How far back those failures are counted |
|
| How long a blocked client stays blocked |
|
| Requests per client per window; |
|
| The rate window |
|
| Log every request header, with anything unrecognised reduced to a sketch |
| (unset) | Korea Investment app key. Unset leaves the KIS mount off entirely |
| (required with the key) | App secret paired with |
| (unset) | Account number, |
PORTCALL_TOKEN gates every mount with Authorization: Bearer <token>. Note
that some MCP clients — Claude's custom connector UI among them — offer no way
to set a request header, so for those the token has to be enforced upstream
instead (or left off, with access controlled at the network layer).
PORTCALL_PATH_PREFIX is the fallback for exactly those clients: it moves every
mount under a segment you choose, so /vault/mcp becomes /<prefix>/vault/mcp
and the URL itself carries the secret. Two things follow from that, and the
server enforces both:
404responses say onlynot_found. They never list what is mounted.The mount listing moves out of the public
/healthzand into/<prefix>/healthz. The bare/healthzstill answers, so liveness probes keep working, but it discloses no paths.
The authenticated health payload also reports blockedClients, the number of
clients the guard is currently holding off. It sits behind the same gate as
the mount list: it is for whoever runs this, and it would otherwise tell a
caller whether their guessing had been noticed.
Treat a path prefix as weaker than a header. URLs reach proxy access logs, crash reports, and anything that records a destination, and a leaked one grants the same access a leaked token would. It raises the bar — it is not authentication.
This log is the one destination the process does control, so the prefix is
reduced there the same way a token is: request lines and the startup banner
read /<prefix a1b2c3d4>/vault/mcp, which still says which mount was hit. A
path that does not carry the prefix is logged as it came, because that is the
caller's guess rather than the secret.
PORTCALL_LOG_HEADERS=true writes out every header a request carried, for
bringing up a new client. What it prints is an allowlist: the ordinary
addressing and content headers in the clear, Authorization as its scheme
plus a length and digest, and everything else as a length and digest alone.
Deciding what to print rather than what to hide is deliberate — a blacklist of
key names only catches the secrets someone thought of, and the one that got
out of this project was stored under the key value.
Which plugins are mounted, and where, is declared in plugins.config.ts.
Running
Requires Node 24 (see .nvmrc).
npm install
npm run build
cp .env.example .env # then set PORTCALL_VAULT_PATH
npm startBoth npm start and npm run dev load .env if it is present and start
without it if it is not, so a daemon can inject the environment directly
instead. Variables already set in the environment are not overridden.
npm run dev runs the entry point through tsx with watch. A daemon should
run the built output, not tsx.
Check it is up:
curl -s localhost:7100/healthzTests
npm test # builds, then runs unit and integration tests
npm run typecheckNo test dependencies: the runner is node:test, and tsx (already needed for
npm run dev) loads the TypeScript.
The integration tests are black-box. They spawn the built server against a
throwaway vault on an ephemeral port and drive it over real HTTP, so they
exercise the same artifact a daemon runs — routing, the /mcp alias, bearer
auth, and both protocol eras. The unit tests cover mount resolution and the
bearer check, where a silent regression would look like a dead client rather
than an error.
Layout
src/
server.ts HTTP entry point, wiring, health, shutdown
routes.ts mount resolution and URL normalisation
auth.ts bearer token check
config.ts environment parsing
log.ts structured logging
types.ts the Plugin interface
adapters/
inProcess.ts library-factory adapter
plugins/
vault/ mcpvault plus image and history tools
index.ts plugin factory
merge.ts fronts mcpvault so extra tools share the mount
image.ts read_image, find_images, write_image, delete_image
git.ts vault_changes, note_history, note_diff, note_at
media.ts format sniffing, header dimensions, sips re-encoding
paths.ts vault confinement, walking, embed/frontmatter/canvas refs
kis/ Korea Investment quotations (read-only)
index.ts plugin factory, process-lifetime token store and client
client.ts quotation allowlist, headers, throttle, error mapping
history.ts cached long price series, paging, split detection
token.ts access-token cache (memory + disk)
tools.ts the quotation tools
plugins.config.ts which plugins mount at which paths
test/
integration.test.ts black-box tests against the built server
routes.test.ts mount resolution
auth.test.ts bearer token check
kis-token.test.ts token caching, single-flight, restart reload
kis-client.test.ts allowlist, headers, paging, KIS error surfacing
kis-tools.test.ts the tool set, read-only hints, Korean business dates,
the order book ladder, chart-range coverage, CSV history
kis-history.test.ts paging, the disk cache, split detection, venue lookup
vault-media.test.ts header parsing, format sniffing, reference extraction,
extension aliases, the private-address filter, download caps
vault-image.test.ts the image tools, driven through the merged server,
plus what survives an upstream that goes away
vault-git.test.ts the history tools against a fixture repository:
Korean paths, unreadable dates, renames, deleted notes
helpers.ts server harness and MCP request buildersLicense
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Stateless MCP gateway and OTel span-streaming bridge for hosted MCP servers.
The MCP server that finds MCP servers. Aggregates Official Registry, Glama, and Smithery.
MCP Gateway: wrap any MCP server with cold-start retries, uptime SLA, and per-execution MPP billing.
Create, deploy, and operate MCP servers directly from your GitHub repositories.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceA universal MCP server that acts as a unified gateway for dynamically connecting and managing multiple MCP servers via a single HTTP endpoint.7 npm6MIT
- AlicenseNot gradedqualityCmaintenanceUnifies multiple MCP servers behind a single endpoint with lazy loading, auto-cleanup, Python plugins, and role-based filtering.2MIT

FlowMCP Serverofficial
AlicenseNot gradedqualityDmaintenanceProvides LocalServer and RemoteServer implementations for running MCP servers locally via stdio or remotely via HTTP/SSE, with simple and advanced deployment options.1MIT- FlicenseNot gradedqualityDmaintenanceA configurable MCP gateway that runs multiple Streamable HTTP MCP servers and exposes all their tools through a single endpoint, enabling tool aggregation and routing for MCP clients.1-