diff --git a/README.md b/README.md index 160a2ec..d97d0a5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ A general-purpose Model Context Protocol interface for Urbit. +The `%mcp` desk ships as a single integrated control plane with three pieces: + +1. **Native MCP server** at `/mcp` — runs Hoon-defined tools, prompts and + resources directly on your ship. +2. **MCP proxy** at `/apps/mcp/mcp` — aggregates the native server plus any + number of remote MCP / OpenAPI / Google Discovery upstreams behind a single + endpoint, with per-server tool filtering and OAuth 2.0 + PKCE token + management. OpenAPI and Discovery spec docs are dynamically converted + to MCP tool calls. +3. **Operator console** at `/apps/mcp/` — a web UI for configuring upstreams, + OAuth providers, the API key, and inspecting tools. + +Both `/mcp` and `/apps/mcp/mcp` authenticate with the same `X-Api-Key` header. +A random key is generated on first install and can be regenerated, set, or +cleared from the operator console. + ## Developer Setup ### 1. Build and Install @@ -16,11 +32,13 @@ Create and mount the desk on your Urbit ship: > |mount %mcp ``` -In the `urbit-mcp` folder, run the [build script](build.sh). By default this will install dependencies into `/dist` in this folder. Use the `-p` argument to additionally copy the %mcp source and its dependencies into your ship's desk. This script will take a minute if it's your first time running it. +In the project folder, run the [build script](build.sh). By default this will +install dependencies into `/dist` in this folder. Use the `-p` argument to +additionally copy the source and its dependencies into your ship's desk. This +script will take a minute if it's your first time running it. ```bash -$ cd urbit-mcp -$ build.sh -p ~/path/to/zod/mcp +$ ./build.sh -p ~/path/to/zod/mcp ``` ```dojo @@ -28,47 +46,57 @@ $ build.sh -p ~/path/to/zod/mcp > |install our %mcp ``` -### 2. Authentication Setup +This installs four agents on your ship: -Get your ship's web login code from the Dojo: +- `%mcp-server` — native MCP server bound to `/mcp` +- `%mcp-proxy` — aggregator bound to `/apps/mcp/api` and `/apps/mcp/mcp` +- `%mcp-fileserver` — serves the operator UI at `/apps/mcp/` +- `%oauth` — OAuth 2.0 + PKCE provider/grant manager -```dojo -> +code -lidlut-tabwed-pillex-ridrup -~zod:dojo> -``` +### 2. Open the operator console -Authenticate and get session cookie: +Visit `http://localhost:PORT/apps/mcp/`. -```bash -curl -i http://localhost:80/~/login -X POST -d "password=lidlut-tabwed-pillex-ridrup" -``` +The console has three tabs: -Extract the cookie from the `set-cookie` header, which will look like this: - -``` -urbauth-~your-ship=0v3.j2062.1prp1.qne4e.goq3h.ksudm -``` +- **Endpoint** — the proxy aggregate URL and your `X-Api-Key`. Buttons to + generate a random key, set a custom one, copy, or clear it. The page also + shows a `claude mcp add` snippet pre-filled with your ship name and key. +- **Upstreams** — configured remote servers. The native server is registered + automatically and tagged `BUILT-IN`. Add your own MCP servers, OpenAPI REST + APIs, or Google Discovery documents. Each upstream can be linked to an OAuth + provider for automatic token injection, and you can allow- or block-list + individual tools. +- **OAuth** — manage OAuth 2.0 + PKCE providers. Connect / disconnect grants, + edit endpoints, store client secrets (the secret is never returned to + the browser; leaving the field blank in an edit preserves the saved value). + OAuth providers can be assigned to upstreams and automatically renew + expired sessions. ### 3A. Register with Claude -Add the MCP server to Claude using HTTP transport: +In the **Endpoint** tab, click `GEN` to mint an API key (or `SET` to use your +own). Then copy the snippet shown under `CLAUDE CLI`, which will look like: ```bash -claude mcp add --transport http zod http://localhost:80/mcp --header "Cookie: urbauth-~your-ship=0v3.j2062.1prp1.qne4e.goq3h.ksudm" --scope user +claude mcp add --transport http zod \ + http://localhost:8080/apps/mcp/mcp \ + --header "X-Api-Key: " ``` + ### 3B. Register with Codex -Codex requires the `mcp-proxy` python package to function. Install with `uvx mcp-proxy`, then append this to your `~/.codex/config.toml`: +Codex needs the `mcp-proxy` python bridge. Install with `uvx mcp-proxy`, then +append to `~/.codex/config.toml`: ```toml -[mcp_servers.fen] +[mcp_servers.zod] command = "uvx" args = [ "mcp-proxy", "--transport", "streamablehttp", - "--headers", "Cookie", "urbauth-~your-ship=0v2.20fhu.t7ki1.cftjr.3s8bv.d9i5l", - "http://localhost:80/mcp" + "--headers", "X-Api-Key", "", + "http://localhost:8080/apps/mcp/mcp" ] ``` diff --git a/desk/app/fileserver/config.hoon b/desk/app/fileserver/config.hoon new file mode 100644 index 0000000..f157805 --- /dev/null +++ b/desk/app/fileserver/config.hoon @@ -0,0 +1,12 @@ +|% +++ web-root ^- (list @t) + /apps/mcp +++ file-root ^- path + /web +++ index ^- $@(~ [~ path]) + `/index/html +++ extension ^- ?(%need %path %fall) + %fall +++ auth ^- $@(? [? (list [path ?])]) + & +-- diff --git a/desk/app/mcp-fileserver.hoon b/desk/app/mcp-fileserver.hoon new file mode 100644 index 0000000..caddba1 --- /dev/null +++ b/desk/app/mcp-fileserver.hoon @@ -0,0 +1,243 @@ +:: mcp-fileserver: serves the web UI from clay +:: +:: copy of foo-fileserver pattern. +:: reads configuration from /app/fileserver/config.hoon. +:: +/= config /app/fileserver/config +:: +|% +++ web-root ^- (list @t) web-root:config +:: +++ defaults + |% + ++ file-root ^- path /web + ++ tombstone ^- ? | + ++ index ^- $@(~ [~ path]) `/index/html + ++ extension ^- ?(%need %path %fall) %need + ++ auth ^- $@(? [? (list [path ?])]) & + -- +:: +++ file-root ^- path + !@(file-root:config file-root:defaults file-root:config) +:: +++ tombstone ^- ? + !@(tombstone:config tombstone:defaults tombstone:config) +:: +++ index ^- $@(?(~ %apache) [~ u=path]) + !@(index:config index:defaults index:config) +:: +++ extension ^- ?(%need %path %fall) + !@(extension:config extension:defaults extension:config) +:: +++ auth ^~ ^- (map path ?) + =/ val=$@(? [? (list [path ?])]) + !@(auth:config auth:defaults auth:config) + ?@ val (~(put by *(map path ?)) / val) + (~(gas by *(map path ?)) [/ -.val] +.val) +-- +:: +|% ++$ state-0 + $: %0 + foot=path + woot=path + cash=(set @t) + == +:: ++$ card card:agent:gall +:: ++$ cart $@(~ $^((lest card) $%([~ card] card))) +++ zang + |= a=(list cart) + ^- (list card) + %- zing + %+ turn a + |= b=cart + ^- (list card) + ?~ b ~ + ?^ -.b b + ?~ -.b [+.b]~ + [b]~ +:: +++ store + |= [url=@t entry=(unit cache-entry:eyre)] + ^- card + [%pass /eyre/cache %arvo %e %set-response url entry] +:: +++ read-next + |= [[our=@p =desk now=@da] =path] + ^- card + =; =task:clay + [%pass [%clay %next path] %arvo %c task] + [%warp our desk ~ %next %z da+now path] +:: +++ set-norm + |= [[our=@p =desk] =path keep=?] + ^- card + =; =task:clay + [%pass [%clay %norm path] %arvo %c task] + [%tomb %norm our desk (~(put of *norm:clay) path keep)] +:: +++ run-tombstone + ^- card + [%pass /clay/tomb %arvo %c %tomb %pick ~] +-- +:: +=| state-0 +=* state - +:: +^- agent:gall +|_ =bowl:gall ++* this . +:: +++ on-init + ^- (quip card _this) + =. foot file-root + =. woot web-root + :_ this + :+ [%pass /eyre/connect %arvo %e %connect [~ web-root] dap.bowl] + (read-next [our q.byk now]:bowl file-root) + ?. tombstone ~ + :~ (set-norm [our q.byk]:bowl file-root |) + run-tombstone + == +:: +++ on-save + !>(state) +:: +++ on-load + |= ole=vase + ^- (quip card _this) + =/ old !<(state-0 ole) + :_ this(foot file-root, woot web-root, cash ~) + %- zang + :~ ?: =(foot.old file-root) ~ + (read-next [our q.byk now]:bowl file-root) + :: + ?. tombstone + (set-norm [our q.byk]:bowl foot.old &) + :~ (set-norm [our q.byk]:bowl file-root |) + run-tombstone + == + :: + (turn ~(tap in cash.old) (curr store ~)) + :: + ?: =(woot.old web-root) ~ + :~ [%pass /eyre/connect %arvo %e %connect [~ woot.old] dap.bowl] + [%pass /eyre/connect %arvo %e %disconnect [~ woot.old]] + [%pass /eyre/connect %arvo %e %connect [~ web-root] dap.bowl] + == + == +:: +++ on-poke + |= [=mark =vase] + ^- (quip card _this) + ?: ?=(%dbug mark) + ~& state=state + [~ this] + ~| mark=mark + ?> ?=(%handle-http-request mark) + =+ !<([rid=@ta inbound-request:eyre] vase) + :: + =; [sav=$@(%| [%& auth=?]) pay=simple-payload:http] + =/ serve=(list card) + =? pay &(?=([%& %&] sav) !authenticated) + [[403 ~] `(as-octs:mimes:html 'unauthorized')] + =? data.pay ?=(%'HEAD' method.request) + ~ + =/ =path /http-response/[rid] + :~ [%give %fact ~[path] [%http-response-header !>(response-header.pay)]] + [%give %fact ~[path] [%http-response-data !>(data.pay)]] + [%give %kick ~[path] ~] + == + [serve this] + ?. ?=(?(%'GET' %'HEAD') method.request) + [%| [405 ~] `(as-octs:mimes:html 'read-only resource')] + =+ ^- [[ext=(unit @ta) site=(list @t)] args=(list [key=@t value=@t])] + =- (fall - [[~ ~] ~]) + (rush url.request ;~(plug apat:de-purl:html yque:de-purl:html)) + ?. =(web-root (scag (lent web-root) site)) + [%| [500 ~] `(as-octs:mimes:html 'bad route')] + :: redirect /apps/mcp to /apps/mcp/ + ?: &(=(web-root site) ?=(~ ext)) + [%| [301 ['location' (cat 3 (spat web-root) '/')]~] ~] + =. site (slag (lent web-root) site) + :- :- %& + |- + ?: =(/ site) (~(got by auth) /) + %- (bond |.(^$(site (snip site)))) + (~(get by auth) site) + =/ target=$@(?(~ %apache) [pax=path ext=@ta]) + |- + ?: &(?=(~ ext) ?=([%$ *] (flop site))) + =+ index=index + ?@ index index + [(weld (snip site) u.index) (rear u.index)] + ?^ ext [(snoc site u.ext) u.ext] + ?- =<(. extension) + %need ~ + %path [site (rear site)] + %fall $(site (snoc site %$)) + == + ?~ target + [[404 ~] `(as-octs:mimes:html 'not found')] + =/ bas=path + /(scot %p our.bowl)/[q.byk.bowl]/(scot %da now.bowl) + ?^ target + =/ =path + :(weld bas file-root pax.target) + ?. .^(? %cu path) + ~& [dap.bowl %not-found path=path] + [[404 ~] `(as-octs:mimes:html 'not found')] + =+ .^(file=^vase %cr path) + =+ ~| [%no-mime-conversion from=ext.target] + .^(=tube:clay %cc (weld bas /[ext.target]/mime)) + =+ !<(=mime (tube file)) + :_ `q.mime + [200 ['content-type' (rsh 3^1 (spat p.mime))] ['cache-control' 'no-cache'] ~] + ?> ?=(%apache target) + [[200 ['content-type' 'text/html;charset=UTF-8']~] `(as-octs:mimes:html 'directory listing not supported')] +:: +++ on-watch + |= =path + ^- (quip card _this) + ?> ?=([%http-response @ ~] path) + [~ this] +:: +++ on-arvo + |= [=wire sign=sign-arvo] + ^- (quip card _this) + ~| wire=wire + ?+ wire !! + [%eyre %connect ~] + ~| sign=+<.sign + ?> ?=(%bound +<.sign) + ~? !accepted.sign [dap.bowl %binding-rejected binding.sign] + [~ this] + :: + [%eyre %cache ~] + ~| sign=+<.sign + ~| %did-not-expect-gift + !! + :: + [%clay %next *] + ?. =(t.t.wire file-root) [~ this] + ~| sign=+<.sign + ?> ?=(%writ +<.sign) + :_ this(cash ~) + %- zang + :+ ?:(tombstone ~ run-tombstone) + (read-next [our q.byk now]:bowl file-root) + (turn ~(tap in cash) (curr store ~)) + == +:: +++ on-leave |=(* [~ this]) +++ on-agent |=(* [~ this]) +++ on-peek |=(* ~) +:: +++ on-fail + |= [=term =tang] + ^- (quip card _this) + %- (slog (rap 3 dap.bowl ' +on-fail: ' term ~) tang) + [~ this] +-- diff --git a/desk/app/mcp-proxy.hoon b/desk/app/mcp-proxy.hoon new file mode 100644 index 0000000..d63a77c --- /dev/null +++ b/desk/app/mcp-proxy.hoon @@ -0,0 +1,1692 @@ +:: mcp-proxy: proxy for remote MCP servers +:: +:: configure remote MCP server endpoints via the web UI. +:: point an LLM at /apps/mcp/mcp for an aggregate endpoint +:: that combines tools from all configured servers. +:: or /apps/mcp/mcp/{server-id} for a single server. +:: +/- mcp-proxy +/- oauth +/+ default-agent, dbug, server +|% ++$ card card:agent:gall +:: ++$ agg-request + $: eyre-id=@ta + req-id=json + method=@t + total=@ud + results=(map server-id:mcp-proxy (unit json)) + == +-- +:: +%- agent:dbug +=| state-4:mcp-proxy +=* state - +=/ pending *(map @t @ta) +=/ wrap-set *(map @t json) :: wire-id -> client's JSON-RPC id (for MCP wrapping) +=/ cookies *(map server-id:mcp-proxy @t) +=/ agg-pending *(map @t agg-request) +=/ spec-cache *(map server-id:mcp-proxy json) +^- agent:gall +=< +|_ =bowl:gall ++* this . + def ~(. (default-agent this %|) bowl) +:: +++ on-init + ^- (quip card _this) + =/ initial-key=@t (gen-token eny.bowl) + =. client-key `initial-key + =/ sid=@tas (self-id our.bowl) + =/ self-url=@t (build-self-url our.bowl now.bowl) + =/ auth-header=header:mcp-proxy ['x-api-key' initial-key] + =/ self-srv=mcp-server:mcp-proxy + :* 'Urbit MCP server' + self-url + ~[auth-header] + %.y ~ %proxy ~ + == + =. servers (~(put by servers) sid self-srv) + =. server-order [sid server-order] + :_ this + :~ [%pass /eyre/connect %arvo %e %connect [~ /apps/mcp/api] %mcp-proxy] + [%pass /eyre/mcp %arvo %e %connect [~ /apps/mcp/mcp] %mcp-proxy] + (sync-server-key-card our.bowl initial-key) + == +:: +++ on-save !>(state) +:: +++ on-load + |= old-state=vase + ^- (quip card _this) + =/ old (mule |.(!<(versioned-state:mcp-proxy old-state))) + ?: ?=(%| -.old) + on-init + =/ eyre-cards=(list card) + :~ [%pass /eyre/connect %arvo %e %connect [~ /apps/mcp/api] %mcp-proxy] + [%pass /eyre/mcp %arvo %e %connect [~ /apps/mcp/mcp] %mcp-proxy] + == + =/ raw-state=state-4:mcp-proxy + ?- -.p.old + %4 p.old + %3 [%4 servers.p.old server-order.p.old tool-filters.p.old ~ ~] + %2 [%4 servers.p.old server-order.p.old ~ ~ ~] + :: + %1 + =/ new-servers=(map server-id:mcp-proxy mcp-server:mcp-proxy) + %- ~(run by servers.p.old) + |=(s=mcp-server-1:mcp-proxy [name.s url.s headers.s enabled.s oauth-provider.s %proxy ~]) + [%4 new-servers server-order.p.old ~ ~ ~] + :: + %0 + =/ new-servers=(map server-id:mcp-proxy mcp-server:mcp-proxy) + %- ~(run by servers.p.old) + |=(s=mcp-server-0:mcp-proxy [name.s url.s headers.s enabled.s ~ %proxy ~]) + [%4 new-servers server-order.p.old ~ ~ ~] + == + :: ensure a client-key exists; generate if missing + =/ ensured-key=@t + ?~ client-key.raw-state (gen-token eny.bowl) + u.client-key.raw-state + :: rename any legacy %urbit-mcp upstream to the @p-derived id, and + :: ensure it exists with the right key + auto-derived loopback URL + =/ sid=@tas (self-id our.bowl) + =/ legacy=(unit mcp-server:mcp-proxy) (~(get by servers.raw-state) %urbit-mcp) + =/ current=(unit mcp-server:mcp-proxy) (~(get by servers.raw-state) sid) + =/ prev=(unit mcp-server:mcp-proxy) ?~(current legacy current) + =/ url=@t + ?~ prev (build-self-url our.bowl now.bowl) + :: if previously stored URL was the old hardcoded port, refresh it + ?: =('http://localhost:8080/mcp' url.u.prev) + (build-self-url our.bowl now.bowl) + url.u.prev + =/ auth-header=header:mcp-proxy ['x-api-key' ensured-key] + =/ self-srv=mcp-server:mcp-proxy + :* 'Urbit MCP server' + url + ~[auth-header] + %.y ~ %proxy ~ + == + =/ servers-no-legacy=(map server-id:mcp-proxy mcp-server:mcp-proxy) + (~(del by servers.raw-state) %urbit-mcp) + =/ patched-servers=(map server-id:mcp-proxy mcp-server:mcp-proxy) + (~(put by servers-no-legacy) sid self-srv) + =/ order-no-legacy=(list server-id:mcp-proxy) + (skip server-order.raw-state |=(s=server-id:mcp-proxy =(s %urbit-mcp))) + =/ patched-order=(list server-id:mcp-proxy) + ?: (~(has in (sy order-no-legacy)) sid) + order-no-legacy + [sid order-no-legacy] + =/ new-state=state-4:mcp-proxy + raw-state(client-key `ensured-key, servers patched-servers, server-order patched-order) + :: re-fetch specs for openapi servers (cache is non-persisted) + =/ spec-cards=(list card) + %+ murn ~(tap by servers.new-state) + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + ?. =(%openapi mode.srv) ~ + ?~ schema-url.srv ~ + %- some + :* %pass /iris/spec/[sid] + %arvo %i %request + [%'GET' u.schema-url.srv ~[['accept' 'application/json']] ~] + *outbound-config:iris + == + :: clear eyre cache for web UI files on every bump + =/ cache-cards=(list card) + %+ turn + :~ '/apps/mcp' + '/apps/mcp/' + '/apps/mcp/index.html' + '/apps/mcp/css/app.css' + '/apps/mcp/js/app.js' + '/apps/mcp/js/api.js' + == + |=(url=@t [%pass /eyre/cache %arvo %e %set-response url ~]) + :: re-sync key with mcp-server every load (idempotent) + =/ sync-cards=(list card) ~[(sync-server-key-card our.bowl ensured-key)] + :_ this(state new-state) + :(weld eyre-cards spec-cards cache-cards sync-cards) +:: +++ on-poke + |= [=mark =vase] + ^- (quip card _this) + |^ + ?+ mark (on-poke:def mark vase) + %mcp-proxy-action + (handle-action !<(action:mcp-proxy vase)) + :: + %json + =/ jon=json !<(json vase) + =/ act=(unit action:mcp-proxy) (parse-json-action jon) + ?~ act `this + (handle-action u.act) + :: + %handle-http-request + =+ !<([eyre-id=@ta req=inbound-request:eyre] vase) + (handle-http eyre-id req) + :: + %noun + :: clear eyre cache for web UI files + =/ urls=(list @t) + :~ '/apps/mcp/' + '/apps/mcp/index.html' + '/apps/mcp/css/app.css' + '/apps/mcp/js/app.js' + '/apps/mcp/js/api.js' + == + ~& [%mcp-proxy %clearing-eyre-cache (lent urls)] + :_ this + %+ turn urls + |=(url=@t [%pass /eyre/cache %arvo %e %set-response url ~]) + == + :: + ++ handle-action + |= act=action:mcp-proxy + ^- (quip card _this) + ?> =(src.bowl our.bowl) + ?- -.act + %add-server + ?: (~(has by servers) id.act) `this + =. servers (~(put by servers) id.act mcp-server.act) + =. server-order (snoc server-order id.act) + ?: ?&(=(%openapi mode.mcp-server.act) ?=(^ schema-url.mcp-server.act)) + (fetch-spec id.act u.schema-url.mcp-server.act) + `this + %remove-server + =. servers (~(del by servers) id.act) + =. server-order (skip server-order |=(s=server-id:mcp-proxy =(s id.act))) + =. cookies (~(del by cookies) id.act) + `this + %update-server + =. servers (~(put by servers) id.act mcp-server.act) + `this + %toggle-server + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) id.act) + ?~ srv `this + =. servers (~(put by servers) id.act u.srv(enabled !enabled.u.srv)) + `this + %refresh-spec + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) id.act) + ?~ srv `this + ?. =(%openapi mode.u.srv) `this + ?~ schema-url.u.srv `this + (fetch-spec id.act u.schema-url.u.srv) + :: + %set-tool-filter + =. tool-filters (~(put by tool-filters) id.act tool-filter.act) + `this + :: + %clear-tool-filter + =. tool-filters (~(del by tool-filters) id.act) + `this + :: + %login-server + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) id.act) + ?~ srv + ~& [%mcp-proxy %server-not-found id.act] + `this + ~& [%mcp-proxy %found-server name.u.srv url.u.srv] + (do-login id.act u.srv) + :: + %set-client-key + =^ cards state (apply-key key.act) + [cards this] + :: + %regenerate-client-key + =^ cards state (apply-key (gen-token eny.bowl)) + [cards this] + :: + %clear-client-key + =. client-key ~ + :_ this + ~[(sync-server-key-card our.bowl '')] + :: + %set-internal-token + :: legacy: a no-op now that mcp-proxy owns the key + `this + == + :: + ++ apply-key + |= new-key=@t + ^- (quip card state-4:mcp-proxy) + =. client-key `new-key + :: ensure self upstream exists with the new key as its x-api-key header + =/ sid=@tas (self-id our.bowl) + =/ prev=(unit mcp-server:mcp-proxy) (~(get by servers) sid) + =/ url=@t + ?~ prev (build-self-url our.bowl now.bowl) + url.u.prev + =/ auth-header=header:mcp-proxy ['x-api-key' new-key] + =/ self-srv=mcp-server:mcp-proxy + :* 'Urbit MCP server' + url + ~[auth-header] + %.y ~ %proxy ~ + == + =. servers (~(put by servers) sid self-srv) + =? server-order !(~(has in (sy server-order)) sid) + [sid server-order] + :_ state + ~[(sync-server-key-card our.bowl new-key)] + :: + ++ do-login + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + ^- (quip card _this) + =/ code=@p + .^(@p %j /(scot %p our.bowl)/code/(scot %da now.bowl)/(scot %p our.bowl)) + =/ pass=@t (scot %p code) + =/ base=@t (get-base-url url.srv) + =/ login-url=@t (cat 3 base '/~/login') + =/ body=@t (cat 3 'password=' pass) + ~& [%mcp-proxy %logging-in login-url] + :_ this + :~ :* %pass /iris/login/[sid] + %arvo %i %request + [%'POST' login-url ~[['content-type' 'application/x-www-form-urlencoded']] `(as-octs:mimes:html body)] + *outbound-config:iris + == + == + :: + ++ fetch-spec + |= [sid=server-id:mcp-proxy url=@t] + ^- (quip card _this) + ~& [%mcp-proxy %fetching-spec sid url] + :_ this + :~ :* %pass /iris/spec/[sid] + %arvo %i %request + [%'GET' url ~[['accept' 'application/json']] ~] + *outbound-config:iris + == + == + :: + ++ handle-http + |= [eyre-id=@ta req=inbound-request:eyre] + ^- (quip card _this) + =/ rl=request-line:server (parse-request-line:server url.request.req) + =/ site=(list @t) site.rl + ?: ?=([%apps %mcp %mcp *] site) + =/ rest=(list @t) t.t.t.site + :: aggregate endpoint: /apps/mcp/mcp or /apps/mcp/mcp/ + :: + ?: |(=(~ rest) ?=([%$ ~] rest)) + (handle-agg eyre-id req) + :: single-server proxy: /apps/mcp/mcp/{server-id} + :: + (handle-mcp eyre-id req rest) + ?. ?=([%apps %mcp %api *] site) + :_ this + (give-http eyre-id 404 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'not found'))) + =/ api-path=(list @t) t.t.t.site + ?. authenticated.req + :_ this + %+ give-simple-payload:app:server eyre-id + (login-redirect:gen:server request.req) + ?: =(%'GET' method.request.req) + (handle-get eyre-id api-path) + ?: =(%'POST' method.request.req) + (handle-post eyre-id req) + :_ this + (give-http eyre-id 405 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'method not allowed'))) + :: + ++ handle-get + |= [eyre-id=@ta site=(list @t)] + ^- (quip card _this) + ?+ site + :_ this + (give-http eyre-id 404 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'not found'))) + [%servers ~] + :_ this + (give-json eyre-id (build-servers-json ~)) + :: + [%client-key ~] + :_ this + %+ give-json eyre-id + %- pairs:enjs:format + :~ :- 'clientKey' + ?~ client-key ~ + s+u.client-key + ['hasKey' b+?=(^ client-key)] + == + :: + [%tools @ ~] + :: list tools for a specific server + =/ sid=server-id:mcp-proxy `@tas`i.t.site + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) sid) + ?~ srv + :_ this + (give-json eyre-id (pairs:enjs:format ~[['tools' a+~]])) + ?: =(%openapi mode.u.srv) + :: openapi: generate from cached spec + =/ spec=(unit json) (~(get by spec-cache) sid) + ?~ spec + :_ this + (give-json eyre-id (pairs:enjs:format ~[['tools' a+~]])) + =/ tools=(list json) (apply-tool-filter sid (spec-to-tools sid u.spec) tool-filters) + :_ this + (give-json eyre-id (pairs:enjs:format ~[['tools' a+tools]])) + :: proxy: fetch tools/list from upstream via iris + =/ upstream-body=@t + %- en:json:html + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] ['method' s+'tools/list'] + ['id' (numb:enjs:format 1)] ['params' (pairs:enjs:format ~)] + == + =/ out-headers=(list [key=@t value=@t]) + %+ weld + ~[['content-type' 'application/json'] ['accept' 'application/json']] + headers.u.srv + =/ cookie=(unit @t) (~(get by cookies) sid) + =? out-headers ?=(^ cookie) + (snoc out-headers ['cookie' u.cookie]) + =/ oauth-hdr=(unit [key=@t value=@t]) + (get-oauth-header oauth-provider.u.srv our.bowl now.bowl) + =? out-headers ?=(^ oauth-hdr) + (snoc out-headers u.oauth-hdr) + =/ wire-id=@t (scot %uv `@uv`eny.bowl) + =. pending (~(put by pending) wire-id eyre-id) + :_ this + :~ [%pass /iris/toolsapi/[wire-id] %arvo %i %request [%'POST' url.u.srv out-headers `(as-octs:mimes:html upstream-body)] *outbound-config:iris] + == + == + :: + ++ handle-post + |= [eyre-id=@ta req=inbound-request:eyre] + ^- (quip card _this) + =/ body=@t + ?~ body.request.req '' + `@t`q.u.body.request.req + =/ jon=(unit json) (de:json:html body) + ?~ jon + :_ this + (give-http eyre-id 400 ~[cors] (some (as-octs:mimes:html '{"error":"bad json"}'))) + =/ act=(unit action:mcp-proxy) (parse-json-action u.jon) + ?~ act + :_ this + (give-http eyre-id 400 ~[cors] (some (as-octs:mimes:html '{"error":"bad action"}'))) + =/ result (handle-action u.act) + :_ +.result + %+ weld -.result + (give-http eyre-id 200 ~[cors] (some (as-octs:mimes:html '{"ok":true}'))) + :: + :: extract x-api-key header value (case-insensitive) + :: + ++ get-api-key-header + |= req=inbound-request:eyre + ^- (unit @t) + =/ hdrs=(list [key=@t value=@t]) header-list.request.req + |- ^- (unit @t) + ?~ hdrs ~ + ?: =((cass (trip key.i.hdrs)) "x-api-key") `value.i.hdrs + $(hdrs t.hdrs) + :: + :: verify x-api-key header matches stored client-key + :: + ++ check-client-key + |= req=inbound-request:eyre + ^- ? + ?~ client-key %.n + =/ supplied=(unit @t) (get-api-key-header req) + ?~ supplied %.n + =(u.supplied u.client-key) + :: + :: aggregate endpoint: combine tools from all servers + :: + ++ handle-agg + |= [eyre-id=@ta req=inbound-request:eyre] + ^- (quip card _this) + :: CORS + ?: =(%'OPTIONS' method.request.req) + :_ this + %- give-http :^ eyre-id 204 + :~ cors + ['access-control-allow-methods' 'GET, POST, DELETE, OPTIONS'] + ['access-control-allow-headers' 'Content-Type, Accept, Authorization, Mcp-Session-Id, X-Api-Key'] + ['access-control-expose-headers' 'Mcp-Session-Id'] + ['access-control-max-age' '86400'] + == + ~ + :: require client-key to be set + ?~ client-key + :_ this + %- give-http :^ eyre-id 503 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"proxy not configured: set an x-api-key via the GUI"}')) + :: verify x-api-key header + ?. (check-client-key req) + :_ this + %- give-http :^ eyre-id 401 + ~[cors ['content-type' 'application/json'] ['www-authenticate' 'X-Api-Key']] + (some (as-octs:mimes:html '{"error":"missing or invalid x-api-key"}')) + :: non-POST: return 200 empty (GET SSE not supported) + ?. =(%'POST' method.request.req) + :_ this + (give-http eyre-id 200 ~[cors] ~) + =/ body=@t + ?~ body.request.req '' + `@t`q.u.body.request.req + =/ jon=(unit json) (de:json:html body) + ?~ jon + :_ this + (give-http eyre-id 400 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html '{"error":"bad json"}'))) + =/ method=@t (get-json-string u.jon 'method') + =/ req-id=json (get-json-field u.jon 'id') + :: + ?+ method + :_ this + (give-http eyre-id 400 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html '{"error":"unknown method"}'))) + :: + %'initialize' + :_ this + =/ resp=json + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] + ['id' req-id] + :- 'result' + %- pairs:enjs:format + :~ :- 'capabilities' + %- pairs:enjs:format + :~ ['tools' (pairs:enjs:format ~[['listChanged' b+|]])] + ['resources' (pairs:enjs:format ~[['listChanged' b+|] ['subscribe' b+|]])] + ['prompts' (pairs:enjs:format ~[['listChanged' b+|]])] + == + :- 'serverInfo' + %- pairs:enjs:format + :~ ['name' s+(crip "{(trip (scot %p our.bowl))} mcp-proxy")] + ['version' s+'1.0.0'] + == + ['protocolVersion' s+'2024-11-05'] + == + == + (give-http eyre-id 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html (en:json:html resp)))) + :: + %'notifications/initialized' + :_ this + (give-http eyre-id 200 ~[cors] ~) + :: + ?(%'tools/list' %'resources/list' %'prompts/list') + (fan-out eyre-id req-id method) + :: + ?(%'tools/call' %'resources/read' %'prompts/get') + ~& [%mcp-proxy %routing-call method (get-json-string (get-json-field u.jon 'params') 'name')] + (route-call eyre-id req u.jon method) + == + :: + :: fan out a list request to all enabled servers + :: + ++ fan-out + |= [eyre-id=@ta req-id=json method=@t] + ^- (quip card _this) + =/ result-key=@t + ?+ method 'items' + %'tools/list' 'tools' + %'resources/list' 'resources' + %'prompts/list' 'prompts' + == + =/ enabled=(list [server-id:mcp-proxy mcp-server:mcp-proxy]) + %+ skim + %+ turn server-order + |=(sid=server-id:mcp-proxy [sid (~(got by servers) sid)]) + |=([* srv=mcp-server:mcp-proxy] enabled.srv) + ?~ enabled + =/ resp=json + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] ['id' req-id] + ['result' (pairs:enjs:format ~[[result-key a+~]])] + == + :_ this + (give-http eyre-id 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html (en:json:html resp)))) + :: separate proxy servers (need Iris) from openapi servers (local) + =/ all=(list [server-id:mcp-proxy mcp-server:mcp-proxy]) + (turn enabled |=([sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] [sid srv])) + =/ proxy-servers=(list [server-id:mcp-proxy mcp-server:mcp-proxy]) + (skim all |=([sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] =(%proxy mode.srv))) + =/ openapi-servers=(list [server-id:mcp-proxy mcp-server:mcp-proxy]) + (skim all |=([sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] =(%openapi mode.srv))) + :: generate openapi results locally from cached specs + =/ local-results=(map server-id:mcp-proxy (unit json)) + %- ~(gas by *(map server-id:mcp-proxy (unit json))) + %+ turn openapi-servers + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + =/ spec=(unit json) (~(get by spec-cache) sid) + ?~ spec [sid ~] + ?. =(%'tools/list' method) + :: openapi only supports tools for now + [sid `(pairs:enjs:format ~[['jsonrpc' s+'2.0'] ['id' (numb:enjs:format 1)] ['result' (pairs:enjs:format ~[[result-key a+~]])]])] + =/ tools=(list json) (apply-tool-filter sid (spec-to-tools sid u.spec) tool-filters) + [sid `(pairs:enjs:format ~[['jsonrpc' s+'2.0'] ['id' (numb:enjs:format 1)] ['result' (pairs:enjs:format ~[['tools' a+tools]])]])] + =/ total=@ud (lent enabled) + :: if no proxy servers, respond immediately with local results + ?. ?=(^ proxy-servers) + =. agg-pending + (~(put by agg-pending) 'immediate' [eyre-id req-id method total local-results]) + :: trigger immediate aggregation via the on-arvo path - but we have all results + :: just combine and respond directly + =/ name-key=@t + ?+ method 'name' + %'tools/list' 'name' %'resources/list' 'uri' %'prompts/list' 'name' + == + =/ all-items=(list json) + %- zing + %+ turn ~(tap by local-results) + |= [s-id=server-id:mcp-proxy res=(unit json)] + ?~ res ~ + =/ result=json (get-json-field u.res 'result') + ?. ?=(%o -.result) ~ + =/ items-json=(unit json) (~(get by p.result) result-key) + ?~ items-json ~ + ?. ?=(%a -.u.items-json) ~ + %+ turn p.u.items-json + |= item=json + ?. ?=(%o -.item) item + =/ orig-name=@t + =/ n=(unit json) (~(get by p.item) name-key) + ?~ n '' ?. ?=(%s -.u.n) '' p.u.n + [%o (~(put by p.item) name-key s+(cat 3 (cat 3 (scot %tas s-id) '_') orig-name))] + =/ resp=json + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] ['id' req-id] + ['result' (pairs:enjs:format ~[[result-key a+all-items]])] + == + :_ this + (give-http eyre-id 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html (en:json:html resp)))) + :: has proxy servers: set up agg-pending with local results pre-populated + =/ group-id=@t (scot %uv `@uv`eny.bowl) + =. agg-pending + (~(put by agg-pending) group-id [eyre-id req-id method total local-results]) + =/ upstream-body=@t + %- en:json:html + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] ['method' s+method] + ['id' (numb:enjs:format 1)] ['params' (pairs:enjs:format ~)] + == + :_ this + %+ turn proxy-servers + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + =/ out-headers=(list [key=@t value=@t]) + %+ weld + ~[['content-type' 'application/json'] ['accept' 'application/json']] + headers.srv + =/ cookie=(unit @t) (~(get by cookies) sid) + =? out-headers ?=(^ cookie) + (snoc out-headers ['cookie' u.cookie]) + =/ oauth-hdr=(unit [key=@t value=@t]) + (get-oauth-header oauth-provider.srv our.bowl now.bowl) + =? out-headers ?=(^ oauth-hdr) + (snoc out-headers u.oauth-hdr) + :* %pass /iris/agg/[group-id]/[sid] + %arvo %i %request + [%'POST' url.srv out-headers `(as-octs:mimes:html upstream-body)] + *outbound-config:iris + == + :: + :: route a call/read/get to a specific server based on name prefix + :: + ++ route-call + |= [eyre-id=@ta req=inbound-request:eyre jon=json method=@t] + ^- (quip card _this) + =/ params=json (get-json-field jon 'params') + =/ req-id=json (get-json-field jon 'id') + =/ name-key=@t + ?+ method 'name' + %'tools/call' 'name' %'resources/read' 'uri' %'prompts/get' 'name' + == + =/ full-name=@t (get-json-string params name-key) + =/ [sid=@t real-name=@t] (split-on-underscore full-name) + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) `@tas`sid) + ?~ srv + :_ this + %- give-http :^ eyre-id 404 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"server not found in tool prefix"}')) + :: build auth headers + =/ out-headers=(list [key=@t value=@t]) + %+ weld + ~[['accept' 'application/json']] + headers.u.srv + =/ cookie=(unit @t) (~(get by cookies) `@tas`sid) + =? out-headers ?=(^ cookie) + (snoc out-headers ['cookie' u.cookie]) + =/ oauth-hdr=(unit [key=@t value=@t]) + (get-oauth-header oauth-provider.u.srv our.bowl now.bowl) + =? out-headers ?=(^ oauth-hdr) + (snoc out-headers u.oauth-hdr) + :: openapi mode: make direct REST API call + ?: =(%openapi mode.u.srv) + ~& [%mcp-proxy %openapi-call sid real-name] + =/ spec=(unit json) (~(get by spec-cache) `@tas`sid) + ?~ spec + ~& [%mcp-proxy %spec-not-cached sid] + :_ this + %- give-http :^ eyre-id 500 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"spec not cached, try again"}')) + =/ op=(unit [path=@t method=@t operation=json]) + (find-operation u.spec real-name) + ?~ op + ~& [%mcp-proxy %op-not-found sid real-name] + :_ this + %- give-http :^ eyre-id 404 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"operation not found in spec"}')) + ~& [%mcp-proxy %found-op path.u.op method.u.op] + :: extract arguments from params + =/ args=json + =/ a=(unit json) ?.(?=(%o -.params) ~ (~(get by p.params) 'arguments')) + (fall a params) + :: build API URL with path params and query string + =/ path-params=(set @t) (extract-path-params path.u.op) + =/ api-url=@t + :: use server URL if set, otherwise derive from spec + =/ base-url=@t + ?: !=('' url.u.srv) url.u.srv + (get-spec-base-url u.spec) + ~& [%mcp-proxy %base-url base-url] + =/ base-with-path=@t (build-api-url base-url path.u.op args) + ~& [%mcp-proxy %base-with-path base-with-path] + =/ qs=@t (build-all-args-query args path-params) + ~& [%mcp-proxy %query-string qs] + (cat 3 base-with-path qs) + ~& [%mcp-proxy %api-url api-url] + :: build body for POST/PUT/PATCH + =/ req-method=method:http + ?+ method.u.op %'GET' + %'get' %'GET' %'post' %'POST' %'put' %'PUT' + %'patch' %'PATCH' %'delete' %'DELETE' + == + =/ has-body=? + ?| =(req-method %'POST') + =(req-method %'PUT') + =(req-method %'PATCH') + == + =/ body=(unit octs) + ?. has-body ~ + `(as-octs:mimes:html (en:json:html args)) + =? out-headers has-body + [['content-type' 'application/json'] out-headers] + :: store eyre-id and use behn to respond from on-arvo + =/ wire-id=@t (scot %uv `@uv`eny.bowl) + =/ client-rpc-id=json (get-json-field jon 'id') + =. pending (~(put by pending) wire-id eyre-id) + =. wrap-set (~(put by wrap-set) wire-id client-rpc-id) + =/ =request:http [req-method api-url out-headers body] + :_ this + :~ [%pass /iris/proxy/[wire-id] %arvo %i %request request *outbound-config:iris] + == + :: proxy mode: forward as MCP request + =/ new-params=json + ?> ?=(%o -.params) + [%o (~(put by p.params) name-key s+real-name)] + =/ new-body=@t + %- en:json:html + ?> ?=(%o -.jon) + [%o (~(put by p.jon) 'params' new-params)] + =. out-headers [['content-type' 'application/json'] out-headers] + =/ wire-id=@t (scot %uv `@uv`eny.bowl) + =. pending (~(put by pending) wire-id eyre-id) + :_ this + :~ :* %pass /iris/proxy/[wire-id] + %arvo %i %request + [%'POST' url.u.srv out-headers `(as-octs:mimes:html new-body)] + *outbound-config:iris + == + == + :: + :: single-server direct proxy (existing behavior) + :: + ++ handle-mcp + |= [eyre-id=@ta req=inbound-request:eyre site=(list @t)] + ^- (quip card _this) + ?: =(%'OPTIONS' method.request.req) + :_ this + %- give-http :^ eyre-id 204 + :~ cors + ['access-control-allow-methods' 'GET, POST, DELETE, OPTIONS'] + ['access-control-allow-headers' 'Content-Type, Accept, Authorization, Mcp-Session-Id, X-Api-Key'] + ['access-control-expose-headers' 'Mcp-Session-Id'] + ['access-control-max-age' '86400'] + == + ~ + :: require client-key + ?~ client-key + :_ this + %- give-http :^ eyre-id 503 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"proxy not configured: set an x-api-key via the GUI"}')) + ?. (check-client-key req) + :_ this + %- give-http :^ eyre-id 401 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"missing or invalid x-api-key"}')) + ?~ site + :_ this + %- give-http :^ eyre-id 400 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"missing server id"}')) + =/ sid=server-id:mcp-proxy i.site + =/ srv=(unit mcp-server:mcp-proxy) (~(get by servers) sid) + ?~ srv + :_ this + %- give-http :^ eyre-id 404 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"server not found"}')) + ?. enabled.u.srv + :_ this + %- give-http :^ eyre-id 503 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"server disabled"}')) + =/ out-headers=(list [key=@t value=@t]) + %+ weld + ~[['content-type' 'application/json'] ['accept' 'application/json, text/event-stream']] + headers.u.srv + =/ cookie=(unit @t) (~(get by cookies) sid) + =? out-headers ?=(^ cookie) + (snoc out-headers ['cookie' u.cookie]) + =/ oauth-hdr=(unit [key=@t value=@t]) + (get-oauth-header oauth-provider.u.srv our.bowl now.bowl) + =? out-headers ?=(^ oauth-hdr) + (snoc out-headers u.oauth-hdr) + =/ session-id=(unit @t) + =/ hdrs=(list [key=@t value=@t]) header-list.request.req + |- + ?~ hdrs ~ + ?: =(key.i.hdrs 'mcp-session-id') `value.i.hdrs + $(hdrs t.hdrs) + =? out-headers ?=(^ session-id) + (snoc out-headers ['mcp-session-id' u.session-id]) + =/ wire-id=@t (scot %uv `@uv`eny.bowl) + =. pending (~(put by pending) wire-id eyre-id) + :_ this + :~ :* %pass /iris/proxy/[wire-id] + %arvo %i %request + [method.request.req url.u.srv out-headers body.request.req] + *outbound-config:iris + == + == + :: + ++ build-servers-json + |= ~ + ^- json + =, enjs:format + %- pairs + :~ ['ship' s+(scot %p our.bowl)] + :- 'servers' + :- %a + %+ turn server-order + |= sid=server-id:mcp-proxy + =/ srv=mcp-server:mcp-proxy (~(got by servers) sid) + =/ has-cookie=? (~(has by cookies) sid) + %- pairs + :~ ['id' s+(scot %tas sid)] + ['name' s+name.srv] + ['url' s+url.srv] + ['enabled' b+enabled.srv] + ['authenticated' b+has-cookie] + ['mode' s+?:(?=(%proxy mode.srv) 'proxy' 'openapi')] + :- 'schemaUrl' + ?~ schema-url.srv ~ + s+u.schema-url.srv + :- 'oauthProvider' + ?~ oauth-provider.srv ~ + s+(scot %tas u.oauth-provider.srv) + ['hasCachedSpec' b+(~(has by spec-cache) sid)] + :- 'toolFilter' + =/ filt=(unit tool-filter:mcp-proxy) (~(get by tool-filters) sid) + ?~ filt ~ + %- pairs:enjs:format + :~ ['mode' s+?:(?=(%allow mode.u.filt) 'allow' 'block')] + ['tools' a+(turn ~(tap in tools.u.filt) |=(t=@t s+t))] + == + :- 'headers' + :- %a + %+ turn headers.srv + |= h=header:mcp-proxy + (pairs ~[['key' s+key.h] ['value' s+value.h]]) + == + == + -- +:: +++ on-watch + |= =path + ^- (quip card _this) + ?+ path (on-watch:def path) + [%http-response @ ~] + `this + == +:: +++ on-arvo + |= [=wire sign=sign-arvo] + ^- (quip card _this) + ?+ wire `this + [%eyre *] + ?: ?=(%bound +<.sign) + ~? !accepted.sign [%mcp-proxy %binding-rejected binding.sign] + `this + `this + :: + [%iris %spec @ ~] + :: OpenAPI spec fetch response + :: + =/ sid=server-id:mcp-proxy i.t.t.wire + ?. ?=([%iris %http-response *] sign) + ~& [%mcp-proxy %spec-fetch-failed sid %bad-sign] + `this + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + ~& [%mcp-proxy %spec-fetch-failed sid %not-finished] + `this + ?. =(200 status-code.response-header.resp) + ~& [%mcp-proxy %spec-fetch-failed sid %status status-code.response-header.resp] + `this + ?~ full-file.resp + ~& [%mcp-proxy %spec-fetch-failed sid %no-body] + `this + =/ body=@t `@t`q.data.u.full-file.resp + =/ jon=(unit json) (de:json:html body) + ?~ jon + ~& [%mcp-proxy %spec-fetch-failed sid %bad-json] + `this + ~& [%mcp-proxy %spec-cached sid] + `this(spec-cache (~(put by spec-cache) sid u.jon)) + :: + [%iris %toolsapi @ ~] + :: tools API response: parse MCP response and extract tools list + =/ wire-id=@t i.t.t.wire + =/ eid=(unit @ta) (~(get by pending) wire-id) + ?~ eid `this + =. pending (~(del by pending) wire-id) + ?. ?=([%iris %http-response *] sign) + :_ this + (give-http u.eid 502 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html '{"tools":[]}'))) + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + :_ this + (give-http u.eid 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html '{"tools":[]}'))) + =/ body=@t + ?~ full-file.resp '' + `@t`q.data.u.full-file.resp + :: strip SSE prefix if present + =/ clean=@t (strip-sse body) + =/ jon=(unit json) (de:json:html clean) + =/ tools=(list json) + ?~ jon ~ + :: MCP response: {"result":{"tools":[...]}} + =/ result=json (get-json-field u.jon 'result') + =/ tl=json (get-json-field result 'tools') + ?. ?=(%a -.tl) ~ + p.tl + =/ resp-body=@t (en:json:html (pairs:enjs:format ~[['tools' a+tools]])) + :_ this + (give-http u.eid 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html resp-body))) + :: + [%iris %login @ ~] + =/ sid=server-id:mcp-proxy i.t.t.wire + ?. ?=([%iris %http-response *] sign) + ~& [%mcp-proxy %login-failed sid %bad-sign] + `this + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + ~& [%mcp-proxy %login-failed sid %not-finished] + `this + ?. =(200 status-code.response-header.resp) + ~& [%mcp-proxy %login-failed sid %status status-code.response-header.resp] + `this + =/ cookie=(unit @t) + =/ hdrs=(list [key=@t value=@t]) headers.response-header.resp + |- + ?~ hdrs ~ + ?: =(key.i.hdrs 'set-cookie') + =/ val=tape (trip value.i.hdrs) + =/ semi=(unit @ud) (find ";" val) + ?~ semi `value.i.hdrs + `(crip (scag u.semi val)) + $(hdrs t.hdrs) + ?~ cookie + ~& [%mcp-proxy %login-failed sid %no-cookie] + `this + ~& [%mcp-proxy %login-ok sid] + `this(cookies (~(put by cookies) sid u.cookie)) + :: + :: + [%iris %proxy @ ~] + =/ wire-id=@t i.t.t.wire + =/ eid=(unit @ta) (~(get by pending) wire-id) + ?~ eid + ~& [%mcp-proxy %no-pending wire-id] + `this + =. pending (~(del by pending) wire-id) + =/ client-id=(unit json) (~(get by wrap-set) wire-id) + =/ needs-wrap=? ?=(^ client-id) + =? wrap-set needs-wrap (~(del by wrap-set) wire-id) + ?. ?=([%iris %http-response *] sign) + :_ this + %- give-http :^ u.eid 502 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"unexpected iris response"}')) + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + :_ this + %- give-http :^ u.eid 502 + ~[cors ['content-type' 'application/json']] + (some (as-octs:mimes:html '{"error":"upstream in progress"}')) + :: for openapi calls, wrap the REST response in MCP format + ?: needs-wrap + =/ body-text=@t + ?~ full-file.resp '' + `@t`q.data.u.full-file.resp + =/ is-error=? (gte status-code.response-header.resp 400) + :: on 401, trigger a token refresh for next call + :: on 401, trigger force-refresh for the oauth provider (fire-and-forget) + :: next call will use the refreshed token + =/ refresh-cards=(list card) + ?. =(401 status-code.response-header.resp) ~ + :: find which server had this wire and get its oauth provider + =/ srv-list=(list [server-id:mcp-proxy mcp-server:mcp-proxy]) + %+ skim ~(tap by servers) + |=([* s=mcp-server:mcp-proxy] ?=(^ oauth-provider.s)) + %+ murn srv-list + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + ?~ oauth-provider.srv ~ + %- some + [%pass /oauth-refresh/[u.oauth-provider.srv] %agent [our.bowl %oauth] %poke %oauth-action !>(`action:oauth`[%force-refresh u.oauth-provider.srv])] + =/ mcp-resp=@t + %- en:json:html + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] + ['id' (fall client-id (numb:enjs:format 1))] + :- 'result' + %- pairs:enjs:format + :~ :- 'content' + :- %a + :~ (pairs:enjs:format ~[['type' s+'text'] ['text' s+body-text]]) + == + ['isError' b+is-error] + == + == + =/ resp-headers=(list [key=@t value=@t]) + ~[cors ['content-type' 'application/json'] ['cache-control' 'no-cache'] ['access-control-expose-headers' 'Mcp-Session-Id'] ['content-encoding' 'identity']] + =/ bod=(unit octs) `(as-octs:mimes:html mcp-resp) + :_ this + =/ =path /http-response/[u.eid] + =/ http-cards=(list card) + :~ [%give %fact ~[path] %http-response-header !>(`response-header:http`[200 resp-headers])] + [%give %fact ~[path] %http-response-data !>(bod)] + [%give %kick ~[path] ~] + == + (weld http-cards refresh-cards) + :: for proxy calls, forward upstream response as-is + =/ resp-headers=(list [key=@t value=@t]) + %+ weld ~[cors ['access-control-expose-headers' 'Mcp-Session-Id']] + %+ skip headers.response-header.resp + |= [key=@t value=@t] + ?|(=(key 'transfer-encoding') =(key 'connection')) + =/ bod=(unit octs) + ?~ full-file.resp ~ + `data.u.full-file.resp + :_ this + =/ =path /http-response/[u.eid] + :~ [%give %fact ~[path] %http-response-header !>(`response-header:http`[status-code.response-header.resp resp-headers])] + [%give %fact ~[path] %http-response-data !>(bod)] + [%give %kick ~[path] ~] + == + :: + [%iris %agg @ @ ~] + :: aggregate response: /iris/agg/{group-id}/{server-id} + :: + =/ group-id=@t i.t.t.wire + =/ sid=server-id:mcp-proxy i.t.t.t.wire + =/ req=(unit agg-request) (~(get by agg-pending) group-id) + ?~ req + ~& [%mcp-proxy %agg-no-pending group-id sid] + `this + :: parse the upstream response (handles both plain JSON and SSE format) + =/ result-json=(unit json) + ?. ?=([%iris %http-response *] sign) ~ + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) ~ + ?. =(200 status-code.response-header.resp) ~ + ?~ full-file.resp ~ + =/ body=@t `@t`q.data.u.full-file.resp + :: strip SSE "data: " prefix if present + =/ clean=@t (strip-sse body) + (de:json:html clean) + :: store result (~ if failed, which is ok) + =/ new-results=(map server-id:mcp-proxy (unit json)) + (~(put by results.u.req) sid result-json) + =/ received=@ud ~(wyt by new-results) + :: not all in yet: update and wait + ?. =(received total.u.req) + =. agg-pending + (~(put by agg-pending) group-id u.req(results new-results)) + `this + :: all responses in: combine and respond + =. agg-pending (~(del by agg-pending) group-id) + =/ result-key=@t + ?+ method.u.req 'items' + %'tools/list' 'tools' + %'resources/list' 'resources' + %'prompts/list' 'prompts' + == + =/ name-key=@t + ?+ method.u.req 'name' + %'tools/list' 'name' + %'resources/list' 'uri' + %'prompts/list' 'name' + == + :: combine items from all servers, prefixing names + =/ all-items=(list json) + %- zing + %+ turn ~(tap by new-results) + |= [s-id=server-id:mcp-proxy res=(unit json)] + ?~ res ~ + =/ result=json (get-json-field u.res 'result') + ?. ?=(%o -.result) ~ + =/ items-json=(unit json) (~(get by p.result) result-key) + ?~ items-json ~ + ?. ?=(%a -.u.items-json) ~ + :: prefix each item's name with server-id_ + %+ turn p.u.items-json + |= item=json + ?. ?=(%o -.item) item + =/ orig-name=@t + =/ n=(unit json) (~(get by p.item) name-key) + ?~ n '' + ?. ?=(%s -.u.n) '' + p.u.n + =/ prefixed=@t (cat 3 (cat 3 (scot %tas s-id) '_') orig-name) + [%o (~(put by p.item) name-key s+prefixed)] + :: build combined response + =/ resp=json + %- pairs:enjs:format + :~ ['jsonrpc' s+'2.0'] + ['id' req-id.u.req] + ['result' (pairs:enjs:format ~[[result-key a+all-items]])] + == + :_ this + (give-http eyre-id.u.req 200 ~[cors ['content-type' 'application/json']] (some (as-octs:mimes:html (en:json:html resp)))) + == +:: +++ on-leave on-leave:def +++ on-agent on-agent:def +++ on-peek + |= =path + ^- (unit (unit cage)) + ?+ path ~ + [%x %dbug %state ~] ``noun+!>(state) + == +:: +++ on-fail on-fail:def +-- +:: +:: helper core +:: +|% +++ cors ['access-control-allow-origin' '*'] +:: +:: generate a random token from entropy (base32 encoded sha) +:: +++ gen-token + |= eny=@ + ^- @t + =/ hash=@ (shaz eny) + :: drop the ~0v prefix from (scot %uv ...) + =/ raw=tape (trip (scot %uv hash)) + (crip (slag 3 raw)) +:: +:: poke mcp-server with the shared API key so /mcp validates against same value +:: caller passes our.bowl since we're in the helper core +:: +++ sync-server-key-card + |= [our=@p key=@t] + ^- card:agent:gall + :* %pass /sync/auth-token + %agent [our %mcp-server] + %poke %set-auth-token + !>(key) + == +:: +:: derive the self-loopback URL by scrying eyre for the bound port +:: +++ build-self-url + |= [our=@p now=@da] + ^- @t + =/ res=(unit [insecure=@ud secure=(unit @ud)]) + =/ m (mule |.(.^([insecure=@ud secure=(unit @ud)] %e /(scot %p our)/ports/(scot %da now)))) + ?:(?=(%& -.m) `p.m ~) + ?~ res 'http://localhost/mcp' + =/ port=@ud insecure.u.res + (rap 3 ~['http://localhost:' (crip (a-co:co port)) '/mcp']) +:: +:: derive the self upstream id from the ship name (no leading tilde) +:: +++ self-id + |= our=@p + ^- @tas + `@tas`(crip (slag 1 (trip (scot %p our)))) +:: +++ http-methods (silt ~['get' 'post' 'put' 'patch' 'delete']) +:: +:: convert an OpenAPI spec to a list of MCP tool JSON objects +:: +++ spec-to-tools + |= [sid=server-id:mcp-proxy spec=json] + ^- (list json) + :: detect format: Google Discovery vs OpenAPI + =/ kind=@t (get-json-string spec 'kind') + ?: =(kind 'discovery#restDescription') + (discovery-to-tools spec) + (openapi-to-tools spec) +:: +:: convert Google Discovery Document to MCP tools +:: +++ discovery-to-tools + |= spec=json + ^- (list json) + =/ resources=json (get-json-field spec 'resources') + ?. ?=(%o -.resources) ~ + =/ res (mule |.((walk-discovery-resources resources))) + ?:(?=(%& -.res) p.res ~) +:: +++ walk-discovery-resources + |= resources=json + ^- (list json) + ?~ resources ~ + ?. ?=(%o -.resources) ~ + %- zing + %+ turn ~(tap by p.resources) + |= [rname=@t robj=json] + ?~ robj ~ + ?. ?=(%o -.robj) ~ + =/ methods=json (get-json-field robj 'methods') + =/ method-tools=(list json) + ?~ methods ~ + ?. ?=(%o -.methods) ~ + %+ murn ~(tap by p.methods) + |= [mname=@t mobj=json] + ^- (unit json) + ?~ mobj ~ + ?. ?=(%o -.mobj) ~ + =/ op-id=@t (get-json-string mobj 'id') + ?: =('' op-id) ~ + =/ desc=@t (get-json-string mobj 'description') + =/ params-obj=json (get-json-field mobj 'parameters') + =/ props=(map @t json) ~ + =/ reqs=(list json) ~ + =? props &(?=(^ params-obj) ?=(%o -.params-obj)) + %- ~(gas by props) + %+ murn ~(tap by p.params-obj) + |= [pname=@t pobj=json] + ^- (unit [@t json]) + ?~ pobj ~ + ?. ?=(%o -.pobj) ~ + =/ ptype=@t (get-json-string pobj 'type') + =/ pdesc=@t (get-json-string pobj 'description') + =/ prop=(map @t json) + (~(put by *(map @t json)) 'type' s+?:(=('' ptype) 'string' ptype)) + =? prop !=('' pdesc) + (~(put by prop) 'description' s+pdesc) + `[pname [%o prop]] + =? reqs &(?=(^ params-obj) ?=(%o -.params-obj)) + %+ murn ~(tap by p.params-obj) + |= [pname=@t pobj=json] + ?~ pobj ~ + ?. ?=(%o -.pobj) ~ + ?. =([~ %b %.y] (~(get by p.pobj) 'required')) ~ + `s+pname + =/ has-req=? (~(has by p.mobj) 'request') + =? props has-req + (~(put by props) 'body' [%o (~(put by *(map @t json)) 'type' s+'string')]) + %- some + %- pairs:enjs:format + :~ ['name' s+op-id] + ['description' s+desc] + :- 'inputSchema' + %- pairs:enjs:format + :~ ['type' s+'object'] + ['properties' [%o props]] + ['required' [%a reqs]] + == + == + =/ sub-resources=json (get-json-field robj 'resources') + =/ sub-tools=(list json) + ?~ sub-resources ~ + ?. ?=(%o -.sub-resources) ~ + (walk-discovery-resources sub-resources) + (weld method-tools sub-tools) +:: +:: convert OpenAPI spec to MCP tools +:: +++ openapi-to-tools + |= spec=json + ^- (list json) + =/ paths=json (get-json-field spec 'paths') + ?. ?=(%o -.paths) ~ + =/ result=(list json) ~ + =/ items=(list [@t json]) ~(tap by p.paths) + |- + ?~ items (flop result) + =/ [path-str=@t path-item=json] i.items + ?. ?=(%o -.path-item) $(items t.items) + =/ meths=(list [@t json]) ~(tap by p.path-item) + =/ path-tools=(list json) + =/ ml=(list [@t json]) meths + |- + ?~ ml ~ + =/ [meth=@t op=json] i.ml + ?. (~(has in http-methods) meth) $(ml t.ml) + ?. ?=(%o -.op) $(ml t.ml) + =/ op-id=@t (get-json-string op 'operationId') + ?: =('' op-id) $(ml t.ml) + =/ desc=@t (get-json-string op 'summary') + =? desc =('' desc) (get-json-string op 'description') + :: skip streaming/webhook by tag + =/ skip=? + =/ tags=(unit json) (~(get by p.op) 'tags') + ?~ tags %.n + ?. ?=(%a -.u.tags) %.n + %+ lien p.u.tags + |= tag=json + ?. ?=(%s -.tag) %.n + =/ lo=tape (cass (trip p.tag)) + ?| !=(~ (find "stream" lo)) + !=(~ (find "webhook" lo)) + == + ?: skip $(ml t.ml) + :: build tool with empty schema (params added later if needed) + =/ tool=json + %- pairs:enjs:format + :~ ['name' s+op-id] + ['description' s+desc] + :- 'inputSchema' + %- pairs:enjs:format + :~ ['type' s+'object'] + ['properties' [%o ~]] + ['required' a+~] + == + == + [tool $(ml t.ml)] + $(items t.items, result (weld path-tools result)) +:: +:: find an OpenAPI operation by operationId and return [path method operation] +:: +++ find-operation + |= [spec=json op-id=@t] + ^- (unit [path=@t method=@t operation=json]) + =/ kind=@t (get-json-string spec 'kind') + ?: =(kind 'discovery#restDescription') + (find-discovery-operation spec op-id) + =/ paths=json (get-json-field spec 'paths') + ?. ?=(%o -.paths) ~ + =/ items=(list [@t json]) ~(tap by p.paths) + |- + ?~ items ~ + =/ [path-str=@t path-item=json] i.items + ?. ?=(%o -.path-item) + $(items t.items) + =/ methods=(list [@t json]) ~(tap by p.path-item) + =/ found=(unit [path=@t method=@t operation=json]) + =/ ml=(list [@t json]) methods + |- + ?~ ml ~ + =/ [m=@t op=json] i.ml + ?. (~(has in http-methods) m) $(ml t.ml) + ?. ?=(%o -.op) $(ml t.ml) + =/ this-id=@t (get-json-string op 'operationId') + ?: =(this-id op-id) `[path-str m op] + $(ml t.ml) + ?^ found found + $(items t.items) +:: +:: build an HTTP request URL from an OpenAPI path template + args +:: +++ find-discovery-operation + |= [spec=json op-id=@t] + ^- (unit [path=@t method=@t operation=json]) + =/ resources=json (get-json-field spec 'resources') + ?~ resources ~ + ?. ?=(%o -.resources) ~ + (search-discovery-resources resources op-id) +:: +++ search-discovery-resources + |= [resources=json op-id=@t] + ^- (unit [path=@t method=@t operation=json]) + ?~ resources ~ + ?. ?=(%o -.resources) ~ + =/ items=(list [@t json]) ~(tap by p.resources) + |- + ?~ items ~ + =/ [rname=@t robj=json] i.items + ?~ robj $(items t.items) + ?. ?=(%o -.robj) $(items t.items) + :: check methods + =/ methods=json (get-json-field robj 'methods') + =/ found=(unit [path=@t method=@t operation=json]) + ?~ methods ~ + ?. ?=(%o -.methods) ~ + =/ ml=(list [@t json]) ~(tap by p.methods) + |- + ?~ ml ~ + =/ [mname=@t mobj=json] i.ml + ?~ mobj $(ml t.ml) + ?. ?=(%o -.mobj) $(ml t.ml) + =/ mid=@t (get-json-string mobj 'id') + ?. =(mid op-id) $(ml t.ml) + =/ http-method=@t (get-json-string mobj 'httpMethod') + =/ mpath=@t + =/ fp=@t (get-json-string mobj 'flatPath') + ?:(=('' fp) (get-json-string mobj 'path') fp) + `[mpath http-method mobj] + ?^ found found + :: recurse sub-resources + =/ sub=json (get-json-field robj 'resources') + =/ sub-found=(unit [path=@t method=@t operation=json]) + ?~ sub ~ + ?. ?=(%o -.sub) ~ + (search-discovery-resources sub op-id) + ?^ sub-found sub-found + $(items t.items) +:: +++ build-api-url + |= [base=@t path-template=@t args=json] + ^- @t + :: substitute {param} in the path with values from args + =/ base-t=tape (trip base) + :: strip trailing / from base + =? base-t &(!=(~ base-t) =('/' (rear base-t))) + (snip base-t) + =/ path-t=tape (trip path-template) + :: ensure a '/' separator between base and path. discovery spec + :: paths (e.g. "users/{userId}/profile") omit the leading slash. + =? path-t &(!=(~ path-t) !=('/' -.path-t)) + ['/' path-t] + =/ result=tape base-t + =/ i=@ud 0 + |- + ?: (gte i (lent path-t)) + (crip result) + =/ c=@ (snag i path-t) + ?. =(c '{') + $(result (snoc result c), i +(i)) + :: find closing } + =/ rest=tape (slag +(i) path-t) + =/ close=(unit @ud) (find "}" rest) + ?~ close + $(result (snoc result c), i +(i)) + =/ param-name=@t (crip (scag u.close rest)) + =/ param-val=@t (get-json-string args param-name) + =/ val-tape=tape (trip param-val) + $(result (weld result val-tape), i (add i (add 2 u.close))) +:: +:: build query string from OpenAPI params + args +:: +++ build-query-string + |= [params=(list json) args=json] + ^- @t + ?. ?=(%o -.args) '' + =/ query-parts=(list @t) + %+ murn params + |= param=json + ?. ?=(%o -.param) ~ + =/ pin=@t (get-json-string param 'in') + ?. =(pin 'query') ~ + =/ pname=@t (get-json-string param 'name') + =/ val=(unit json) (~(get by p.args) pname) + ?~ val ~ + ?. ?=(%s -.u.val) ~ + ?: =('' p.u.val) ~ + `(cat 3 pname (cat 3 '=' p.u.val)) + ?~ query-parts '' + =/ result=@t i.query-parts + =/ rest=(list @t) t.query-parts + |- + ?~ rest (cat 3 '?' result) + $(result (cat 3 result (cat 3 '&' i.rest)), rest t.rest) +:: +++ get-spec-base-url + |= spec=json + ^- @t + =/ kind=@t (get-json-string spec 'kind') + ?: =(kind 'discovery#restDescription') + :: Google Discovery: use baseUrl or rootUrl + =/ base=@t (get-json-string spec 'baseUrl') + ?:(=('' base) (get-json-string spec 'rootUrl') base) + :: OpenAPI: use servers[0].url + =/ servers=json (get-json-field spec 'servers') + ?. ?=(%a -.servers) '' + ?~ p.servers '' + (get-json-string i.p.servers 'url') +:: +++ apply-tool-filter + |= [sid=server-id:mcp-proxy tools=(list json) filters=(map server-id:mcp-proxy tool-filter:mcp-proxy)] + ^- (list json) + =/ filt=(unit tool-filter:mcp-proxy) (~(get by filters) sid) + ?~ filt tools + %+ skim tools + |= tool=json + =/ tool-name=@t (get-json-string tool 'name') + ?- mode.u.filt + %allow (~(has in tools.u.filt) tool-name) + %block !(~(has in tools.u.filt) tool-name) + == +:: +++ extract-path-params + |= path-template=@t + ^- (set @t) + =/ t=tape (trip path-template) + =/ result=(set @t) ~ + |- + ?~ t result + ?. =(i.t '{') $(t t.t) + =/ rest=tape t.t + =/ close=(unit @ud) (find "}" rest) + ?~ close result + =/ param=@t (crip (scag u.close rest)) + $(t (slag +(u.close) rest), result (~(put in result) param)) +:: +++ build-all-args-query + |= [args=json exclude=(set @t)] + ^- @t + ?. ?=(%o -.args) '' + =/ items=(list [@t json]) ~(tap by p.args) + =/ parts=(list @t) + %+ murn items + |= [key=@t val=json] + ^- (unit @t) + ?: (~(has in exclude) key) ~ + :: skip null values — json `~` is the atom 0 and crashes -.val + ?~ val ~ + =/ v=@t + ?+ -.val '' + %s p.val + %n p.val + %b ?:(p.val 'true' 'false') + == + ?: =('' v) ~ + `(cat 3 key (cat 3 '=' v)) + ?~ parts '' + =/ result=@t i.parts + =/ rest=(list @t) t.parts + |- + ?~ rest (cat 3 '?' result) + $(result (cat 3 result (cat 3 '&' i.rest)), rest t.rest) +:: +++ get-optional-string + |= [jon=json key=@t] + ^- (unit @t) + ?. ?=(%o -.jon) ~ + =/ v=(unit json) (~(get by p.jon) key) + ?~ v ~ + ?. ?=(%s -.u.v) ~ + ?: =('' p.u.v) ~ + `p.u.v +:: +++ get-optional-tas + |= [jon=json key=@t] + ^- (unit @tas) + ?. ?=(%o -.jon) ~ + =/ v=(unit json) (~(get by p.jon) key) + ?~ v ~ + ?. ?=(%s -.u.v) ~ + ?: =('' p.u.v) ~ + ``@tas`p.u.v +:: +++ get-oauth-header + |= [oauth-prov=(unit @tas) our=@p now=@da] + ^- (unit [key=@t value=@t]) + ?~ oauth-prov ~ + :: use the auth-header scry which checks expiry + =/ hdr=@t + =/ res (mule |.(.^(@t %gx /(scot %p our)/oauth/(scot %da now)/auth-header/[u.oauth-prov]/noun))) + ?:(?=(%& -.res) p.res '') + ?: =('' hdr) ~ + `['authorization' hdr] +:: +++ strip-sse + |= body=@t + ^- @t + =/ t=tape (trip body) + ?. =("data: " (scag 6 t)) body + =/ rest=tape (slag 6 t) + :: trim trailing whitespace/newlines by flipping and dropping + %- crip %- flop + =/ r=tape (flop rest) + |- ^- tape + ?~ r ~ + ?: ?|(=(10 i.r) =(13 i.r) =(32 i.r)) + $(r t.r) + r +:: +++ get-base-url + |= url=@t + ^- @t + =/ t=tape (trip url) + =/ scheme-mark=(unit @ud) (find "://" t) + ?~ scheme-mark url + =/ after-scheme=@ud (add 3 u.scheme-mark) + =/ rest=tape (slag after-scheme t) + =/ path-start=(unit @ud) (find "/" rest) + ?~ path-start url + (crip (scag (add after-scheme u.path-start) t)) +:: +++ get-json-field + |= [jon=json key=@t] + ^- json + ?~ jon ~ + ?. ?=(%o -.jon) ~ + (fall (~(get by p.jon) key) ~) +:: +++ get-json-string + |= [jon=json key=@t] + ^- @t + =/ v=json (get-json-field jon key) + ?~ v '' + ?: ?=(%s -.v) p.v + ?: ?=(%n -.v) p.v + ?: ?=(%b -.v) ?:(p.v 'true' 'false') + '' +:: +++ split-on-underscore + |= name=@t + ^- [@t @t] + =/ t=tape (trip name) + =/ idx=(unit @ud) (find "_" t) + ?~ idx [name ''] + [(crip (scag u.idx t)) (crip (slag +(u.idx) t))] +:: +++ parse-json-action + |= jon=json + ^- (unit action:mcp-proxy) + =/ res (mule |.((parse-json-action-raw jon))) + ?: ?=(%& -.res) `p.res + ~ +:: +++ parse-json-action-raw + |= jon=json + ^- action:mcp-proxy + =, dejs:format + =/ typ=@t ((ot ~[action+so]) jon) + ?+ typ !! + %'add-server' + =/ f + %- ot + :~ id+so name+so url+so + headers+(ar (ot ~[key+so value+so])) + == + =/ [id=@t name=@t url=@t headers=(list header:mcp-proxy)] (f jon) + =/ oprov=(unit @tas) (get-optional-tas jon 'oauth-provider') + =/ surl=(unit @t) (get-optional-string jon 'schema-url') + =/ md=server-mode:mcp-proxy + =/ m=@t (get-json-string jon 'mode') + ?:(=('openapi' m) %openapi %proxy) + [%add-server `@tas`id [name url headers %.y oprov md surl]] + %'remove-server' + [%remove-server `@tas`((ot ~[id+so]) jon)] + %'update-server' + =/ f + %- ot + :~ id+so name+so url+so + headers+(ar (ot ~[key+so value+so])) + enabled+bo + == + =/ [id=@t name=@t url=@t headers=(list header:mcp-proxy) enabled=?] (f jon) + =/ oprov=(unit @tas) (get-optional-tas jon 'oauth-provider') + =/ surl=(unit @t) (get-optional-string jon 'schema-url') + =/ md=server-mode:mcp-proxy + =/ m=@t (get-json-string jon 'mode') + ?:(=('openapi' m) %openapi %proxy) + [%update-server `@tas`id [name url headers enabled oprov md surl]] + %'toggle-server' + [%toggle-server `@tas`((ot ~[id+so]) jon)] + %'refresh-spec' + [%refresh-spec `@tas`((ot ~[id+so]) jon)] + %'set-tool-filter' + =/ id=@t (get-json-string jon 'id') + =/ fmode=@t (get-json-string jon 'mode') + =/ tool-list=(list json) + =/ v=json (get-json-field jon 'tools') + ?. ?=(%a -.v) ~ + p.v + =/ tool-set=(set @t) + %- silt + %+ murn tool-list + |=(j=json ?.(?=(%s -.j) ~ `p.j)) + [%set-tool-filter `@tas`id [?:(?=(%'allow' fmode) %allow %block) tool-set]] + %'clear-tool-filter' + [%clear-tool-filter `@tas`((ot ~[id+so]) jon)] + %'login-server' + [%login-server `@tas`((ot ~[id+so]) jon)] + %'set-client-key' + [%set-client-key (get-json-string jon 'key')] + %'regenerate-client-key' + [%regenerate-client-key ~] + %'clear-client-key' + [%clear-client-key ~] + == +:: +++ server-to-json + |= [sid=server-id:mcp-proxy srv=mcp-server:mcp-proxy] + ^- json + =, enjs:format + %- pairs + :~ ['id' s+(scot %tas sid)] + ['name' s+name.srv] + ['url' s+url.srv] + ['enabled' b+enabled.srv] + :- 'headers' :- %a + %+ turn headers.srv + |= h=header:mcp-proxy + (pairs ~[['key' s+key.h] ['value' s+value.h]]) + == +:: +++ give-http + |= [eyre-id=@ta status=@ud headers=(list [@t @t]) body=(unit octs)] + ^- (list card) + %+ give-simple-payload:app:server eyre-id + [[status headers] body] +:: +++ give-json + |= [eyre-id=@ta jon=json] + ^- (list card) + %+ give-simple-payload:app:server eyre-id + (json-response:gen:server jon) +-- diff --git a/desk/app/mcp-server.hoon b/desk/app/mcp-server.hoon index 97f76c8..f559cab 100644 --- a/desk/app/mcp-server.hoon +++ b/desk/app/mcp-server.hoon @@ -39,6 +39,7 @@ +$ card card:agent:gall +$ versioned-state $% state-0 + state-1 == +$ state-0 $: %0 @@ -46,10 +47,25 @@ prompts=(set prompt:mcp) resources=(set resource:mcp) == ++$ state-1 + $: %1 + tools=(set tool:mcp) + prompts=(set prompt:mcp) + resources=(set resource:mcp) + auth-token=@t :: auto-generated x-api-key for /mcp + == +:: extract x-api-key header value (case-insensitive) +++ get-api-key + |= headers=(list [key=@t value=@t]) + ^- (unit @t) + |- ^- (unit @t) + ?~ headers ~ + ?: =((cass (trip key.i.headers)) "x-api-key") `value.i.headers + $(headers t.headers) -- %- agent:dbug ^- agent:gall -=| state-0 +=| state-1 =* state - %+ verb | |_ =bowl:gall @@ -67,11 +83,19 @@ |= =vase ^- (quip card _this) =/ old !<(versioned-state vase) + =/ new=state-1 + ?- -.old + %1 old + %0 + :* %1 + tools.old + prompts.old + resources.old + '' :: await mcp-proxy poke + == + == :- ~ - ?- -.old - %0 - this(state old) - == + this(state new) :: ++ on-init ^- (quip card _this) @@ -108,6 +132,12 @@ :: %handle-http-request (handle-req !<([@ta inbound-request:eyre] vase)) + :: + %set-auth-token + :: poked by mcp-proxy to keep keys in sync + ?> =(src our):bowl + =. auth-token !<(@t vase) + `this :: ?(%add-tool %add-prompt %add-resource) ?> =(src our):bowl @@ -125,9 +155,17 @@ ++ handle-req |= [eyre-id=@ta req=inbound-request:eyre] ^- (quip card _this) - ?. authenticated.req + :: auth: require x-api-key header matching auth-token + ?: =('' auth-token) :_ this - (send-event eyre-id (internal:error:rpc:ml 'Authentication required' ~)) + (send-event eyre-id (internal:error:rpc:ml 'Server not configured' ~)) + =/ supplied=(unit @t) (get-api-key header-list.request.req) + ?~ supplied + :_ this + (send-event eyre-id (internal:error:rpc:ml 'Missing x-api-key header' ~)) + ?. =(u.supplied auth-token) + :_ this + (send-event eyre-id (internal:error:rpc:ml 'Invalid x-api-key' ~)) ?+ method.request.req [(simple-response eyre-id 405 ~) this] :: @@ -469,6 +507,11 @@ :: read prompt definitions [%x %prompts ~] ``json+!>((mcp-prompts-to-json:ml prompts)) + :: + :: .^(@t %gx /=mcp-server=/auth-token/noun) + :: read the auto-generated auth token (for internal use) + [%x %auth-token ~] + ``noun+!>(auth-token) == ++ on-arvo |= [=(pole knot) =sign-arvo] diff --git a/desk/app/oauth.hoon b/desk/app/oauth.hoon new file mode 100644 index 0000000..f34ad77 --- /dev/null +++ b/desk/app/oauth.hoon @@ -0,0 +1,1104 @@ +:: oauth: OAuth 2.0 + PKCE token management agent +:: +:: manages provider configs, handles browser auth flows, +:: stores tokens, auto-refreshes before expiry. +:: other agents scry or subscribe for tokens. +:: +/- oauth +/+ default-agent, dbug, server +|% ++$ card card:agent:gall +-- +:: +%- agent:dbug +=| state-0:oauth +=* state - +=/ refreshing *(set provider-id:oauth) :: in-flight refresh locks (non-persisted) +^- agent:gall +=< +|_ =bowl:gall ++* this . + def ~(. (default-agent this %|) bowl) +:: +++ on-init + ^- (quip card _this) + :_ this + :~ [%pass /eyre/connect %arvo %e %connect [~ /oauth] %oauth] + == +:: +++ on-save !>(state) +:: +++ on-load + |= old-state=vase + ^- (quip card _this) + =/ old (mule |.(!<(versioned-state:oauth old-state))) + ?: ?=(%| -.old) + on-init + ?- -.p.old + %0 + :: re-register refresh timers for all grants with expiry + =/ eyre-cards=(list card) + :~ [%pass /eyre/connect %arvo %e %connect [~ /oauth] %oauth] + == + =/ timer-cards=(list card) + %+ murn ~(tap by grants.p.old) + |= [pid=provider-id:oauth gra=grant:oauth] + ?~ expires-at.gra ~ + ?~ refresh-token.gra ~ + =/ refresh-time=@da + =/ margin=@dr ~m5 + ?: (gth u.expires-at.gra (add now.bowl margin)) + (sub u.expires-at.gra margin) + (add now.bowl ~s5) + `[%pass /timer/refresh/[pid] %arvo %b %wait refresh-time] + :_ this(state p.old) + (weld eyre-cards timer-cards) + == +:: +++ on-poke + |= [=mark =vase] + ^- (quip card _this) + |^ + ?+ mark (on-poke:def mark vase) + %oauth-action + (handle-action !<(action:oauth vase)) + :: + %handle-http-request + =+ !<([eyre-id=@ta req=inbound-request:eyre] vase) + (handle-http eyre-id req) + == + :: + ++ handle-action + |= act=action:oauth + ^- (quip card _this) + ?> =(our src):bowl + ?- -.act + :: + %add-provider + ?: (~(has by providers) id.act) + ~|(%oauth-provider-exists !!) + =. providers (~(put by providers) id.act config.act) + `this + :: + %remove-provider + =. providers (~(del by providers) id.act) + =. grants (~(del by grants) id.act) + `this + :: + %update-provider + :: preserve existing client-secret if the new one is empty + =/ existing=(unit provider-config:oauth) (~(get by providers) id.act) + =/ new-cfg=provider-config:oauth config.act + =? new-cfg ?&(?=(^ existing) =('' client-secret.new-cfg)) + new-cfg(client-secret client-secret.u.existing) + =. providers (~(put by providers) id.act new-cfg) + `this + :: + %connect + =/ cfg=(unit provider-config:oauth) (~(get by providers) id.act) + ?~ cfg + ~|(%oauth-provider-not-found !!) + :: generate PKCE verifier and state + :: + =/ raw-eny=@ eny.bowl + =/ state-param=@t (scot %uv `@uv`raw-eny) + =/ verifier=@t (make-verifier raw-eny) + =/ challenge=@t (make-challenge verifier) + :: store pending auth + :: + =/ pend=pending-auth:oauth + [state-param verifier id.act] + =. pending (~(put by pending) state-param pend) + :: build auth URL + :: + =/ auth=@t + %+ build-auth-url u.cfg + [state-param challenge] + :: return the URL as a JSON response via fact on /redirects + :: + :_ this + :~ [%give %fact [/redirects]~ %json !>((frond:enjs:format 'url' s+auth))] + == + :: + %disconnect + =/ had=? (~(has by grants) id.act) + =. grants (~(del by grants) id.act) + ?. had `this + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%grant-removed id.act])] + == + :: + %force-refresh + :: trigger immediate token refresh (called by %mcp-proxy on 401) + =/ gra=(unit grant:oauth) (~(get by grants) id.act) + ?~ gra `this + ?~ refresh-token.u.gra `this + ?: (~(has in refreshing) id.act) `this + =/ cfg=(unit provider-config:oauth) (~(get by providers) id.act) + ?~ cfg `this + =. refreshing (~(put in refreshing) id.act) + =/ body=@t + %+ rap 3 + :~ 'grant_type=refresh_token' + '&refresh_token=' + u.refresh-token.u.gra + == + =/ basic-auth=@t (make-basic-auth client-id.u.cfg client-secret.u.cfg) + ~& [%oauth %force-refresh id.act] + :_ this + :~ :* %pass /iris/token-refresh/[id.act] + %arvo %i %request + :* %'POST' + token-url.u.cfg + :~ ['content-type' 'application/x-www-form-urlencoded'] + ['accept' 'application/json'] + ['authorization' basic-auth] + == + `(as-octs:mimes:html body) + == + *outbound-config:iris + == + == + :: + %revoke + =/ gra=(unit grant:oauth) (~(get by grants) id.act) + ?~ gra + ~|(%oauth-no-grant !!) + =/ cfg=(unit provider-config:oauth) (~(get by providers) id.act) + ?~ cfg + ~|(%oauth-provider-not-found !!) + ?~ revoke-url.u.cfg + :: no revoke endpoint, just disconnect + :: + =. grants (~(del by grants) id.act) + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%grant-removed id.act])] + == + :: POST revoke request + :: + =/ body=@t + %+ rap 3 + :~ 'token=' + access-token.u.gra + '&client_id=' + client-id.u.cfg + == + :_ this + :~ :* %pass /iris/revoke/[id.act] + %arvo %i %request + :* %'POST' + u.revoke-url.u.cfg + ~[['content-type' 'application/x-www-form-urlencoded']] + `(as-octs:mimes:html body) + == + *outbound-config:iris + == + == + == + :: + :: HTTP request handler + :: + ++ handle-http + |= [eyre-id=@ta req=inbound-request:eyre] + ^- (quip card _this) + =/ rl=request-line:server (parse-request-line:server url.request.req) + =/ site=(list @t) site.rl + :: /oauth/callback — handle OAuth redirect + :: + ?: ?=([%oauth %callback *] site) + (handle-callback eyre-id req) + :: /oauth/api/* — JSON API + :: + ?: ?=([%oauth %api *] site) + ?. authenticated.req + :_ this + %+ give-simple-payload:app:server eyre-id + (login-redirect:gen:server request.req) + (handle-api eyre-id req t.t.site) + :: /oauth or /oauth/ — redirect to main MCP proxy UI + :: + ?: ?| ?=([%oauth ~] site) + ?=([%oauth %$ ~] site) + == + :_ this + (give-http eyre-id 307 ~[['location' '/apps/mcp/']] ~) + :: /oauth/manage — old direct UI (kept for backward compat) + ?: ?=([%oauth %manage ~] site) + ?. authenticated.req + :_ this + %+ give-simple-payload:app:server eyre-id + (login-redirect:gen:server request.req) + :_ this + (give-http eyre-id 200 ~[['content-type' 'text/html']] (some (as-octs:mimes:html index-html))) + :: /oauth/css/app.css + :: + ?: ?=([%oauth %css %app ~] site) + :_ this + (give-http eyre-id 200 ~[['content-type' 'text/css']] (some (as-octs:mimes:html app-css))) + :: /oauth/js/* + :: + ?: ?=([%oauth %js %app ~] site) + :_ this + (give-http eyre-id 200 ~[['content-type' 'application/javascript']] (some (as-octs:mimes:html app-js))) + ?: ?=([%oauth %js %api ~] site) + :_ this + (give-http eyre-id 200 ~[['content-type' 'application/javascript']] (some (as-octs:mimes:html api-js))) + :: 404 + :: + :_ this + (give-http eyre-id 404 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'not found'))) + :: + :: handle OAuth callback from provider + :: + ++ handle-callback + |= [eyre-id=@ta req=inbound-request:eyre] + ^- (quip card _this) + =/ rl=request-line:server (parse-request-line:server url.request.req) + =/ params=(list [key=@t value=@t]) args.rl + =/ code=(unit @t) (get-param params 'code') + =/ st=(unit @t) (get-param params 'state') + :: validate params + :: + ?~ code + :_ this + (give-http eyre-id 400 ~[['content-type' 'text/html']] (some (as-octs:mimes:html '

Error: missing code parameter

'))) + ?~ st + :_ this + (give-http eyre-id 400 ~[['content-type' 'text/html']] (some (as-octs:mimes:html '

Error: missing state parameter

'))) + :: look up pending auth + :: + =/ pend=(unit pending-auth:oauth) (~(get by pending) u.st) + ?~ pend + :_ this + (give-http eyre-id 400 ~[['content-type' 'text/html']] (some (as-octs:mimes:html '

Error: unknown state parameter (expired or invalid)

'))) + :: look up provider config + :: + =/ cfg=(unit provider-config:oauth) (~(get by providers) provider-id.u.pend) + ?~ cfg + =. pending (~(del by pending) u.st) + :_ this + (give-http eyre-id 400 ~[['content-type' 'text/html']] (some (as-octs:mimes:html '

Error: provider no longer configured

'))) + :: build token exchange request + :: + =/ body=@t + %+ rap 3 + :~ 'grant_type=authorization_code' + '&code=' + u.code + '&redirect_uri=' + redirect-uri.u.cfg + '&code_verifier=' + verifier.u.pend + == + =/ basic-auth=@t (make-basic-auth client-id.u.cfg client-secret.u.cfg) + :: send token exchange via iris, serve wait page + :: + :_ this + %+ weld + :~ :* %pass /iris/token-exchange/[u.st] + %arvo %i %request + :* %'POST' + token-url.u.cfg + :~ ['content-type' 'application/x-www-form-urlencoded'] + ['accept' 'application/json'] + ['authorization' basic-auth] + == + `(as-octs:mimes:html body) + == + *outbound-config:iris + == + == + (give-http eyre-id 200 ~[['content-type' 'text/html']] (some (as-octs:mimes:html callback-html))) + :: + :: JSON API handler + :: + ++ handle-api + |= [eyre-id=@ta req=inbound-request:eyre site=(list @t)] + ^- (quip card _this) + ?: =(%'GET' method.request.req) + ?+ site + :_ this + (give-http eyre-id 404 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'not found'))) + :: + [%providers ~] + :_ this + (give-json eyre-id (build-providers-json ~)) + :: + [%grants ~] + :_ this + (give-json eyre-id (build-grants-json ~)) + == + ?: =(%'POST' method.request.req) + =/ body=@t + ?~ body.request.req '' + `@t`q.u.body.request.req + =/ jon=(unit json) (de:json:html body) + ?~ jon + :_ this + (give-http eyre-id 400 ~[['content-type' 'application/json']] (some (as-octs:mimes:html '{"error":"bad json"}'))) + =/ act=(unit action:oauth) (action-from-json u.jon) + ?~ act + :_ this + (give-http eyre-id 400 ~[['content-type' 'application/json']] (some (as-octs:mimes:html '{"error":"bad action"}'))) + :: special handling for %connect: return auth URL in response + :: + ?: ?=(%connect -.u.act) + =/ cfg=(unit provider-config:oauth) (~(get by providers) id.u.act) + ?~ cfg + :_ this + (give-http eyre-id 404 ~[['content-type' 'application/json']] (some (as-octs:mimes:html '{"error":"provider not found"}'))) + =/ raw-eny=@ eny.bowl + =/ state-param=@t (scot %uv `@uv`raw-eny) + =/ verifier=@t (make-verifier raw-eny) + =/ challenge=@t (make-challenge verifier) + =/ pend=pending-auth:oauth [state-param verifier id.u.act] + =. pending (~(put by pending) state-param pend) + =/ auth=@t (build-auth-url u.cfg [state-param challenge]) + =/ resp=@t (en:json:html (frond:enjs:format 'url' s+auth)) + :_ this + (give-http eyre-id 200 ~[['content-type' 'application/json']] (some (as-octs:mimes:html resp))) + :: all other actions + :: + =/ result (handle-action u.act) + :_ +.result + %+ weld -.result + (give-http eyre-id 200 ~[['content-type' 'application/json']] (some (as-octs:mimes:html '{"ok":true}'))) + :_ this + (give-http eyre-id 405 ~[['content-type' 'text/plain']] (some (as-octs:mimes:html 'method not allowed'))) + -- +:: +++ on-watch + |= =path + ^- (quip card _this) + ?+ path (on-watch:def path) + [%http-response @ ~] + `this + :: + [%grants ~] + ?> =(our.bowl src.bowl) + :: send initial grant state + :: + :_ this + %+ turn ~(tap by grants) + |= [pid=provider-id:oauth gra=grant:oauth] + [%give %fact ~ %oauth-update !>(`update:oauth`[%grant-added pid gra])] + :: + [%redirects ~] + `this + == +:: +++ on-agent on-agent:def +:: +++ on-arvo + |= [=wire sign=sign-arvo] + ^- (quip card _this) + ?+ wire `this + :: + [%eyre *] + ?: ?=(%bound +<.sign) + ~? !accepted.sign [%oauth %binding-rejected binding.sign] + `this + `this + :: + :: token exchange response + :: + [%iris %token-exchange @ ~] + =/ st=@t i.t.t.wire + ?. ?=([%iris %http-response *] sign) + ~& [%oauth %token-exchange-failed st %bad-sign] + `this + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + ~& [%oauth %token-exchange-failed st %not-finished] + `this + ?. =(200 status-code.response-header.resp) + ~& [%oauth %token-exchange-failed st %status status-code.response-header.resp] + =. pending (~(del by pending) st) + `this + ?~ full-file.resp + ~& [%oauth %token-exchange-failed st %no-body] + =. pending (~(del by pending) st) + `this + =/ body=@t `@t`q.data.u.full-file.resp + =/ jon=(unit json) (de:json:html body) + ?~ jon + ~& [%oauth %token-exchange-failed st %bad-json] + =. pending (~(del by pending) st) + `this + :: parse token response + :: + =/ pend=(unit pending-auth:oauth) (~(get by pending) st) + ?~ pend + ~& [%oauth %token-exchange-failed st %no-pending] + `this + =/ gra=(unit grant:oauth) (parse-token-response u.jon provider-id.u.pend now.bowl) + ?~ gra + ~& [%oauth %token-exchange-failed st %parse-failed] + =. pending (~(del by pending) st) + `this + :: store grant, clear pending + :: + =. grants (~(put by grants) provider-id.u.pend u.gra) + =. pending (~(del by pending) st) + :: notify subscribers + set refresh timer + :: + =/ cards=(list card) + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%grant-added provider-id.u.pend u.gra])] + == + =? cards ?=(^ expires-at.u.gra) + =/ refresh-time=@da + =/ exp=@da u.expires-at.u.gra + =/ margin=@dr ~m5 + ?: (gth exp (add now.bowl margin)) + (sub exp margin) + (add now.bowl ~s30) + (snoc cards [%pass /timer/refresh/[provider-id.u.pend] %arvo %b %wait refresh-time]) + [cards this] + :: + :: token refresh response + :: + [%iris %token-refresh @ ~] + =/ pid=provider-id:oauth i.t.t.wire + =. refreshing (~(del in refreshing) pid) + ?. ?=([%iris %http-response *] sign) + ~& [%oauth %refresh-failed pid %bad-sign] + `this + =/ resp=client-response:iris client-response.sign + ?. ?=(%finished -.resp) + ~& [%oauth %refresh-failed pid %not-finished] + `this + ?. =(200 status-code.response-header.resp) + :: check for invalid_grant (requires re-auth, not retry) + =/ err-body=@t + ?~ full-file.resp '' + `@t`q.data.u.full-file.resp + =/ is-invalid=? + !=(~ (find "invalid_grant" (trip err-body))) + ~& [%oauth %refresh-failed pid %status status-code.response-header.resp ?:(is-invalid %invalid-grant %other)] + :: remove grant if invalid_grant (forces re-auth) + =? grants is-invalid (~(del by grants) pid) + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%token-expired pid])] + == + ?~ full-file.resp + ~& [%oauth %refresh-failed pid %no-body] + `this + =/ body=@t `@t`q.data.u.full-file.resp + =/ jon=(unit json) (de:json:html body) + ?~ jon + ~& [%oauth %refresh-failed pid %bad-json] + `this + =/ gra=(unit grant:oauth) (parse-token-response u.jon pid now.bowl) + ?~ gra + ~& [%oauth %refresh-failed pid %parse-failed] + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%token-expired pid])] + == + :: preserve refresh token if new one not provided + :: + =/ old=(unit grant:oauth) (~(get by grants) pid) + =/ final=grant:oauth + ?: &(?=(^ old) ?=(~ refresh-token.u.gra)) + u.gra(refresh-token refresh-token.u.old) + u.gra + =. grants (~(put by grants) pid final) + =/ cards=(list card) + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%grant-refreshed pid final])] + == + =? cards ?=(^ expires-at.final) + =/ refresh-time=@da + =/ exp=@da u.expires-at.final + =/ margin=@dr ~m5 + ?: (gth exp (add now.bowl margin)) + (sub exp margin) + (add now.bowl ~s30) + (snoc cards [%pass /timer/refresh/[pid] %arvo %b %wait refresh-time]) + [cards this] + :: + :: revoke response + :: + [%iris %revoke @ ~] + =/ pid=provider-id:oauth i.t.t.wire + =. grants (~(del by grants) pid) + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%grant-removed pid])] + == + :: + :: refresh timer + :: + [%timer %refresh @ ~] + =/ pid=provider-id:oauth i.t.t.wire + ?. ?=([%behn %wake *] sign) `this + :: single-flight: skip if already refreshing + ?: (~(has in refreshing) pid) `this + =/ gra=(unit grant:oauth) (~(get by grants) pid) + ?~ gra `this + ?~ refresh-token.u.gra + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%token-expired pid])] + == + =/ cfg=(unit provider-config:oauth) (~(get by providers) pid) + ?~ cfg + :_ this + :~ [%give %fact [/grants]~ %oauth-update !>(`update:oauth`[%token-expired pid])] + == + =. refreshing (~(put in refreshing) pid) + =/ body=@t + %+ rap 3 + :~ 'grant_type=refresh_token' + '&refresh_token=' + u.refresh-token.u.gra + == + =/ basic-auth=@t (make-basic-auth client-id.u.cfg client-secret.u.cfg) + :_ this + :~ :* %pass /iris/token-refresh/[pid] + %arvo %i %request + :* %'POST' + token-url.u.cfg + :~ ['content-type' 'application/x-www-form-urlencoded'] + ['accept' 'application/json'] + ['authorization' basic-auth] + == + `(as-octs:mimes:html body) + == + *outbound-config:iris + == + == + == +:: +++ on-leave on-leave:def +:: +++ on-peek + |= =path + ^- (unit (unit cage)) + ?> =(our src):bowl + ?+ path [~ ~] + [%x %grant @ ~] + =/ pid=provider-id:oauth `@tas`i.t.t.path + =/ gra=(unit grant:oauth) (~(get by grants) pid) + ?~ gra [~ ~] + ``noun+!>(u.gra) + :: + [%x %providers ~] + ``noun+!>(providers) + :: + [%x %has-grant @ ~] + =/ pid=provider-id:oauth `@tas`i.t.t.path + ``noun+!>((~(has by grants) pid)) + :: + :: /x/token/: get access token as @t + :: returns the token if valid, or '' if expired/missing + :: callers should poke %oauth with %connect if '' is returned + :: + [%x %token @ ~] + =/ pid=provider-id:oauth `@tas`i.t.t.path + =/ gra=(unit grant:oauth) (~(get by grants) pid) + ?~ gra ``noun+!>(`@t`'') + :: check if expired + ?: ?& ?=(^ expires-at.u.gra) + (lth u.expires-at.u.gra now.bowl) + == + ``noun+!>(`@t`'') + ``noun+!>(access-token.u.gra) + :: + :: /x/auth-header/: get full Authorization header + :: e.g. "Bearer xxx" - ready to use as header value + :: + [%x %auth-header @ ~] + =/ pid=provider-id:oauth `@tas`i.t.t.path + =/ gra=(unit grant:oauth) (~(get by grants) pid) + ?~ gra ``noun+!>(`@t`'') + ?: ?& ?=(^ expires-at.u.gra) + (lth u.expires-at.u.gra now.bowl) + == + ``noun+!>(`@t`'') + ``noun+!>((rap 3 ~[token-type.u.gra ' ' access-token.u.gra])) + == +:: +++ on-fail on-fail:def +-- +:: +:: helper core +:: +|% +:: +:: PKCE helpers +:: +++ make-basic-auth + |= [client-id=@t client-secret=@t] + ^- @t + =/ creds=@t (rap 3 ~[client-id ':' client-secret]) + =/ encoded=@t (en:base64:mimes:html [(met 3 creds) creds]) + (rap 3 ~['Basic ' encoded]) +:: +++ make-verifier + |= eny=@ + ^- @t + :: generate 43-char base64url string from entropy + :: shax takes an atom, returns a 256-bit hash as @ + :: + =/ raw=@ (shax eny) + =/ b64=@t (en:base64:mimes:html [32 raw]) + (safe-scag 43 (base64-to-url b64)) +:: +++ make-challenge + |= verifier=@t + ^- @t + :: SHA-256 hash of verifier bytes, base64url encoded + :: trip the cord to get bytes, then hash as atom + :: + =/ vt=tape (trip verifier) + =/ hash=@ (shax (crip vt)) + =/ b64=@t (en:base64:mimes:html [32 hash]) + (base64-to-url b64) +:: +++ base64-to-url + |= b64=@t + ^- @t + :: convert standard base64 to base64url: + :: replace + with -, / with _, strip = padding + :: + %- crip + %+ turn + %+ skip (trip b64) + |=(c=@tD =(c '=')) + |= c=@tD + ?: =(c '+') '-' + ?: =(c '/') '_' + c +:: +++ safe-scag + |= [n=@ud t=@t] + ^- @t + (crip (scag n (trip t))) +:: +:: URL builder +:: +++ build-auth-url + |= [cfg=provider-config:oauth state=@t challenge=@t] + ^- @t + %+ rap 3 + :~ auth-url.cfg + '?client_id=' + client-id.cfg + '&redirect_uri=' + redirect-uri.cfg + '&response_type=code' + '&state=' + state + '&code_challenge=' + challenge + '&code_challenge_method=S256' + '&scope=' + scopes.cfg + == +:: +:: query param extractor +:: +++ get-param + |= [params=(list [key=@t value=@t]) key=@t] + ^- (unit @t) + =/ match=(list [key=@t value=@t]) + (skim params |=([k=@t v=@t] =(k key))) + ?~ match ~ + `value.i.match +:: +:: token response parser +:: +++ parse-token-response + |= [jon=json pid=provider-id:oauth now=@da] + ^- (unit grant:oauth) + =/ res (mule |.((parse-token-json jon pid now))) + ?: ?=(%& -.res) `p.res + ~ +:: +++ parse-token-json + |= [jon=json pid=provider-id:oauth now=@da] + ^- grant:oauth + ?> ?=(%o -.jon) + =/ at=@t + =/ v=(unit json) (~(get by p.jon) 'access_token') + ?~ v '' + ?. ?=(%s -.u.v) '' + p.u.v + =/ rt=(unit @t) + =/ v=(unit json) (~(get by p.jon) 'refresh_token') + ?~ v ~ + ?. ?=(%s -.u.v) ~ + `p.u.v + =/ tt=@t + =/ v=(unit json) (~(get by p.jon) 'token_type') + ?~ v 'Bearer' + ?. ?=(%s -.u.v) 'Bearer' + p.u.v + =/ exp=(unit @da) + =/ v=(unit json) (~(get by p.jon) 'expires_in') + ?~ v ~ + ?. ?=(%n -.u.v) ~ + =/ secs=(unit @ud) (slaw %ud p.u.v) + ?~ secs ~ + `(add now (mul u.secs ~s1)) + =/ sc=@t + =/ v=(unit json) (~(get by p.jon) 'scope') + ?~ v '' + ?. ?=(%s -.u.v) '' + p.u.v + [at rt tt exp sc pid] +:: +:: JSON action parser (for HTTP API) +:: +++ action-from-json + |= jon=json + ^- (unit action:oauth) + =/ res (mule |.((action-from-json-raw jon))) + ?: ?=(%& -.res) `p.res + ~ +:: +++ action-from-json-raw + |= jon=json + ^- action:oauth + =, dejs:format + =/ typ=@t ((ot ~[action+so]) jon) + ?+ typ !! + %'add-provider' + =/ f + %- ot + :~ id+so + auth-url+so + token-url+so + revoke-url+(mu so) + client-id+so + client-secret+so + redirect-uri+so + scopes+so + == + =/ [id=@t auth-url=@t token-url=@t revoke-url=(unit @t) client-id=@t client-secret=@t redirect-uri=@t scopes=@t] + (f jon) + [%add-provider `@tas`id [auth-url token-url revoke-url client-id client-secret redirect-uri scopes]] + :: + %'remove-provider' + [%remove-provider `@tas`((ot ~[id+so]) jon)] + :: + %'connect' + [%connect `@tas`((ot ~[id+so]) jon)] + :: + %'disconnect' + [%disconnect `@tas`((ot ~[id+so]) jon)] + :: + %'revoke' + [%revoke `@tas`((ot ~[id+so]) jon)] + :: + %'force-refresh' + [%force-refresh `@tas`((ot ~[id+so]) jon)] + == +:: +:: JSON builders +:: +++ build-providers-json + |= ~ + ^- json + =, enjs:format + %- pairs + :~ :- 'providers' + :- %a + %+ turn ~(tap by providers) + |= [pid=provider-id:oauth cfg=provider-config:oauth] + %- pairs + :~ ['id' s+(scot %tas pid)] + ['name' s+(scot %tas pid)] + ['authUrl' s+auth-url.cfg] + ['tokenUrl' s+token-url.cfg] + :- 'revokeUrl' + ?~ revoke-url.cfg ~ + s+u.revoke-url.cfg + ['clientId' s+client-id.cfg] + ['redirectUri' s+redirect-uri.cfg] + ['scopes' s+scopes.cfg] + ['hasSecret' b+!=('' client-secret.cfg)] + ['hasGrant' b+(~(has by grants) pid)] + == + == +:: +++ build-grants-json + |= ~ + ^- json + =, enjs:format + %- pairs + :~ :- 'grants' + :- %a + %+ turn ~(tap by grants) + |= [pid=provider-id:oauth gra=grant:oauth] + %- pairs + :~ ['providerId' s+(scot %tas pid)] + ['tokenType' s+token-type.gra] + ['scopes' s+scopes.gra] + :- 'expiresAt' + ?~ expires-at.gra ~ + s+(scot %da u.expires-at.gra) + ['hasRefreshToken' b+?=(^ refresh-token.gra)] + == + == +:: +:: HTTP helpers +:: +++ give-http + |= [eyre-id=@ta status=@ud headers=(list [@t @t]) body=(unit octs)] + ^- (list card) + %+ give-simple-payload:app:server eyre-id + [[status headers] body] +:: +++ give-json + |= [eyre-id=@ta jon=json] + ^- (list card) + %+ give-simple-payload:app:server eyre-id + (json-response:gen:server jon) +:: +:: static content +:: +++ callback-html + ^- @t + ''' + + + OAuth - Processing + +
+

Authorization received

+

Exchanging token... you can close this tab.

+

Back to OAuth Manager

+
+ + + ''' +:: +++ index-html + ^- @t + ''' + + + + + + OAuth Manager + + + +
+
+

OAuth Manager

+

Manage OAuth provider connections for your ship

+
+
+
+

Add Provider

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+

Providers

+
+
+
+
+ + + + + ''' +:: +++ app-css + ^- @t + ''' + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #1a1a1a; padding: 2rem; max-width: 720px; margin: 0 auto; } + header { margin-bottom: 2rem; } + h1 { font-size: 1.5rem; font-weight: 600; } + h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.75rem; } + .subtitle { color: #666; font-size: 0.85rem; margin-top: 0.25rem; } + section { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; } + .form-row { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; } + .form-row label { flex: 1; display: flex; flex-direction: column; font-size: 0.8rem; color: #555; gap: 0.25rem; } + input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.85rem; } + button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem; background: #1a1a1a; color: #fff; } + button:hover { background: #333; } + button.secondary { background: #e0e0e0; color: #1a1a1a; } + button.danger { background: #d32f2f; color: #fff; } + button.danger:hover { background: #b71c1c; } + button.success { background: #2e7d32; color: #fff; } + button.success:hover { background: #1b5e20; } + .form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } + .provider-card { background: #fafafa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 1rem; margin-bottom: 0.75rem; } + .provider-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } + .provider-name { font-weight: 600; } + .provider-url { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; } + .provider-actions { display: flex; gap: 0.5rem; } + .badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 500; } + .badge.connected { background: #e8f5e9; color: #2e7d32; } + .badge.disconnected { background: #fce4ec; color: #c62828; } + .empty { color: #999; font-size: 0.85rem; text-align: center; padding: 2rem; } + .toast { position: fixed; bottom: 1rem; right: 1rem; background: #1a1a1a; color: #fff; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; opacity: 0; transition: opacity 0.3s; } + .toast.show { opacity: 1; } + ''' +:: +++ app-js + ^- @t + ''' + var OAuthApp = { + providers: [], + init: function() { + this.loadProviders(); + this.bindEvents(); + }, + loadProviders: function() { + var self = this; + OAuthAPI.getProviders().then(function(data) { + self.providers = data.providers || []; + self.render(); + }).catch(function(e) { + console.error('Failed to load providers:', e); + self.providers = []; + self.render(); + }); + }, + bindEvents: function() { + var self = this; + document.getElementById('add-form').addEventListener('submit', function(e) { + e.preventDefault(); + var f = e.target; + var data = { + action: 'add-provider', + id: f.elements['id'].value.trim().toLowerCase(), + 'auth-url': f.elements['auth-url'].value.trim(), + 'token-url': f.elements['token-url'].value.trim(), + 'revoke-url': f.elements['revoke-url'].value.trim() || null, + 'client-id': f.elements['client-id'].value.trim(), + 'client-secret': f.elements['client-secret'].value.trim(), + 'redirect-uri': f.elements['redirect-uri'].value.trim(), + scopes: f.elements['scopes'].value.trim() + }; + OAuthAPI.post(data).then(function() { + f.reset(); + self.loadProviders(); + self.toast('Provider added'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + }, + connect: function(id) { + var self = this; + OAuthAPI.post({ action: 'connect', id: id }).then(function(data) { + if (data && data.url) { + window.location.href = data.url; + } else { + self.toast('Connect initiated'); + self.loadProviders(); + } + }).catch(function(e) { alert('Connect failed: ' + e.message); }); + }, + disconnect: function(id) { + var self = this; + OAuthAPI.post({ action: 'disconnect', id: id }).then(function() { + self.loadProviders(); + self.toast('Disconnected'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + remove: function(id) { + if (!confirm('Remove this provider?')) return; + var self = this; + OAuthAPI.post({ action: 'remove-provider', id: id }).then(function() { + self.loadProviders(); + self.toast('Provider removed'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + render: function() { + var container = document.getElementById('providers'); + if (this.providers.length === 0) { + container.innerHTML = '
No providers configured. Add one above.
'; + return; + } + var html = ''; + for (var i = 0; i < this.providers.length; i++) { + var p = this.providers[i]; + var status = p.hasGrant ? 'connected' : 'disconnected'; + html += '
' + + '
' + + '' + this.esc(p.id) + '' + + '' + status + '' + + '
' + + '
' + this.esc(p.authUrl) + '
' + + '
Scopes: ' + this.esc(p.scopes) + '
' + + '
' + + (p.hasGrant + ? '' + : '') + + '' + + '
' + + '
'; + } + container.innerHTML = html; + }, + toast: function(msg) { + var el = document.getElementById('toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'toast'; + el.className = 'toast'; + document.body.appendChild(el); + } + el.textContent = msg; + el.classList.add('show'); + setTimeout(function() { el.classList.remove('show'); }, 2000); + }, + esc: function(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + }; + document.addEventListener('DOMContentLoaded', function() { OAuthApp.init(); }); + ''' +:: +++ api-js + ^- @t + ''' + var OAuthAPI = { + base: '/oauth/api', + getProviders: function() { + return fetch(this.base + '/providers', { credentials: 'same-origin' }).then(function(r) { return r.json(); }); + }, + getGrants: function() { + return fetch(this.base + '/grants', { credentials: 'same-origin' }).then(function(r) { return r.json(); }); + }, + post: function(data) { + return fetch(this.base, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(function(r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }); + } + }; + ''' +-- diff --git a/desk/desk.bill b/desk/desk.bill index 7c342f9..1666d31 100644 --- a/desk/desk.bill +++ b/desk/desk.bill @@ -1,2 +1,5 @@ :~ %mcp-server + %mcp-proxy + %mcp-fileserver + %oauth == diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 new file mode 100644 index 0000000..7d5e2de --- /dev/null +++ b/desk/desk.docket-0 @@ -0,0 +1,9 @@ +:~ title+'Urbit MCP' + info+'Allow LLM ship piloting & proxy remote MCP servers through your ship' + color+0x7c.3aed + version+[0 2 0] + website+'' + license+'MIT' + base+'mcp' + site+/apps/mcp +== diff --git a/desk/lib/docket.hoon b/desk/lib/docket.hoon new file mode 100644 index 0000000..ef39b7f --- /dev/null +++ b/desk/lib/docket.hoon @@ -0,0 +1,223 @@ +/- *docket +|% +:: +++ mime + |% + +$ draft + $: title=(unit @t) + info=(unit @t) + color=(unit @ux) + glob-http=(unit [=url hash=@uvH]) + glob-ames=(unit [=ship hash=@uvH]) + base=(unit term) + site=(unit path) + image=(unit url) + version=(unit version) + website=(unit url) + license=(unit cord) + == + :: + ++ finalize + |= =draft + ^- (unit docket) + ?~ title.draft ~ + ?~ info.draft ~ + ?~ color.draft ~ + ?~ version.draft ~ + ?~ website.draft ~ + ?~ license.draft ~ + =/ href=(unit href) + ?^ site.draft `[%site u.site.draft] + ?~ base.draft ~ + ?^ glob-http.draft + `[%glob u.base hash.u.glob-http %http url.u.glob-http]:draft + ?~ glob-ames.draft + ~ + `[%glob u.base hash.u.glob-ames %ames ship.u.glob-ames]:draft + ?~ href ~ + =, draft + :- ~ + :* %1 + u.title + u.info + u.color + u.href + image + u.version + u.website + u.license + == + :: + ++ from-clauses + =| =draft + |= cls=(list clause) + ^- (unit docket) + =* loop $ + ?~ cls (finalize draft) + =* clause i.cls + =. draft + ?- -.clause + %title draft(title `title.clause) + %info draft(info `info.clause) + %color draft(color `color.clause) + %glob-http draft(glob-http `[url hash]:clause) + %glob-ames draft(glob-ames `[ship hash]:clause) + %base draft(base `base.clause) + %site draft(site `path.clause) + %image draft(image `url.clause) + %version draft(version `version.clause) + %website draft(website `website.clause) + %license draft(license `license.clause) + == + loop(cls t.cls) + :: + ++ to-clauses + |= d=docket + ^- (list clause) + %- zing + :~ :~ title+title.d + info+info.d + color+color.d + version+version.d + website+website.d + license+license.d + == + ?~ image.d ~ ~[image+u.image.d] + ?: ?=(%site -.href.d) ~[site+path.href.d] + =/ ref=glob-reference glob-reference.href.d + :~ base+base.href.d + ?- -.location.ref + %http [%glob-http url.location.ref hash.ref] + %ames [%glob-ames ship.location.ref hash.ref] + == == == + :: + ++ spit-clause + |= =clause + ^- tape + %+ weld " {(trip -.clause)}+" + ?+ -.clause "'{(trip +.clause)}'" + %color (scow %ux color.clause) + %site (spud path.clause) + :: + %glob-http + "['{(trip url.clause)}' {(scow %uv hash.clause)}]" + :: + %glob-ames + "[{(scow %p ship.clause)} {(scow %uv hash.clause)}]" + :: + %version + =, version.clause + "[{(scow %ud major)} {(scow %ud minor)} {(scow %ud patch)}]" + == + :: + ++ spit-docket + |= dock=docket + ^- tape + ;: welp + ":~\0a" + `tape`(zing (join "\0a" (turn (to-clauses dock) spit-clause))) + "\0a==" + == + -- +:: +++ enjs + =, enjs:format + |% + :: + ++ charge-update + |= u=^charge-update + ^- json + %+ frond -.u + ^- json + ?- -.u + %del-charge s+desk.u + :: + %initial + %- pairs + %+ turn ~(tap by initial.u) + |=([=desk c=^charge] [desk (charge c)]) + :: + %add-charge + %- pairs + :~ desk+s+desk.u + charge+(charge charge.u) + == + == + :: + ++ num + |= a=@u + ^- ^tape + =/ p=json (numb a) + ?> ?=(%n -.p) + (trip p.p) + :: + ++ version + |= v=^version + ^- json + :- %s + %- crip + "{(num major.v)}.{(num minor.v)}.{(num patch.v)}" + :: + ++ merge + |= [a=json b=json] + ^- json + ?> &(?=(%o -.a) ?=(%o -.b)) + [%o (~(uni by p.a) p.b)] + :: + ++ href + |= h=^href + %+ frond -.h + ?- -.h + %site s+(spat path.h) + %glob + %- pairs + :~ base+s+base.h + glob-reference+(glob-reference glob-reference.h) + == + == + :: + ++ glob-reference + |= ref=^glob-reference + %- pairs + :~ hash+s+(scot %uv hash.ref) + location+(glob-location location.ref) + == + :: + ++ glob-location + |= loc=^glob-location + ^- json + %+ frond -.loc + ?- -.loc + %http s+url.loc + %ames s+(scot %p ship.loc) + == + :: + ++ charge + |= c=^charge + %+ merge (docket docket.c) + %- pairs + :~ chad+(chad chad.c) + == + :: + ++ docket + |= d=^docket + ^- json + %- pairs + :~ title+s+title.d + info+s+info.d + color+s+(scot %ux color.d) + href+(href href.d) + image+?~(image.d ~ s+u.image.d) + version+(version version.d) + license+s+license.d + website+s+website.d + == + :: + ++ chad + |= c=^chad + %+ frond -.c + ?+ -.c ~ + %hung s+err.c + == + -- +-- diff --git a/desk/mar/css.hoon b/desk/mar/css.hoon new file mode 100644 index 0000000..7bf327b --- /dev/null +++ b/desk/mar/css.hoon @@ -0,0 +1,13 @@ +=, mimes:html +|_ mud=@t +++ grow + |% + ++ mime [/text/css (as-octs mud)] + -- +++ grab + |% + ++ noun @t + ++ mime |=([p=mite q=octs] (@t q.q)) + -- +++ grad %mime +-- diff --git a/desk/mar/docket-0.hoon b/desk/mar/docket-0.hoon new file mode 100644 index 0000000..c3b253b --- /dev/null +++ b/desk/mar/docket-0.hoon @@ -0,0 +1,25 @@ +/+ dock=docket +|_ =docket:dock +++ grow + |% + ++ mime + ^- ^mime + [/text/x-docket (as-octt:mimes:html (spit-docket:mime:dock docket))] + ++ noun docket + ++ json (docket:enjs:dock docket) + -- +++ grab + |% + :: + ++ mime + |= [=mite len=@ud tex=@] + ^- docket:dock + %- need + %- from-clauses:mime:dock + !<((list clause:dock) (slap !>(~) (ream tex))) + + :: + ++ noun docket:dock + -- +++ grad %noun +-- diff --git a/desk/mar/html.hoon b/desk/mar/html.hoon new file mode 100644 index 0000000..98ce7fb --- /dev/null +++ b/desk/mar/html.hoon @@ -0,0 +1,13 @@ +=, html +|_ htm=@t +++ grow + |% + ++ mime [/text/html (met 3 htm) htm] + -- +++ grab + |% + ++ noun @t + ++ mime |=([p=mite q=octs] q.q) + -- +++ grad %mime +-- diff --git a/desk/mar/jam.hoon b/desk/mar/jam.hoon new file mode 100644 index 0000000..cbbd524 --- /dev/null +++ b/desk/mar/jam.hoon @@ -0,0 +1,17 @@ +:: +:::: /hoon/jam/mar + :: +/? 310 +:: +=, mimes:html +|_ mud=@ +++ grow + |% + ++ mime [/application/x-urb-jam (as-octs mud)] + -- +++ grab + |% :: convert from + ++ noun @ :: clam from %noun + -- +++ grad %mime +-- diff --git a/desk/mar/js.hoon b/desk/mar/js.hoon new file mode 100644 index 0000000..a3e77a4 --- /dev/null +++ b/desk/mar/js.hoon @@ -0,0 +1,13 @@ +=, mimes:html +|_ mud=@t +++ grow + |% + ++ mime [/application/javascript (as-octs mud)] + -- +++ grab + |% + ++ noun @t + ++ mime |=([p=mite q=octs] (@t q.q)) + -- +++ grad %mime +-- diff --git a/desk/mar/mcp-proxy-action.hoon b/desk/mar/mcp-proxy-action.hoon new file mode 100644 index 0000000..39e486f --- /dev/null +++ b/desk/mar/mcp-proxy-action.hoon @@ -0,0 +1,96 @@ +/- mcp-proxy +|_ act=action:mcp-proxy +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action:mcp-proxy + ++ json + |= jon=^json + ^- action:mcp-proxy + =, dejs:format + =/ typ=@t ((ot ~[action+so]) jon) + =/ get-opt-tas + |= key=@t + ^- (unit @tas) + ?. ?=(%o -.jon) ~ + =/ v=(unit ^json) (~(get by p.jon) key) + ?~ v ~ + ?. ?=(%s -.u.v) ~ + ?: =('' p.u.v) ~ + ``@tas`p.u.v + =/ get-opt-str + |= key=@t + ^- (unit @t) + ?. ?=(%o -.jon) ~ + =/ v=(unit ^json) (~(get by p.jon) key) + ?~ v ~ + ?. ?=(%s -.u.v) ~ + ?: =('' p.u.v) ~ + `p.u.v + =/ get-str + |= key=@t + ^- @t + ?. ?=(%o -.jon) '' + =/ v=(unit ^json) (~(get by p.jon) key) + ?~ v '' + ?. ?=(%s -.u.v) '' + p.u.v + =/ get-mode + ^- server-mode:mcp-proxy + ?: =('openapi' (get-str 'mode')) %openapi %proxy + ?+ typ !! + %'add-server' + =/ f + %- ot + :~ id+so name+so url+so + headers+(ar (ot ~[key+so value+so])) + == + =/ [id=@t name=@t url=@t headers=(list header:mcp-proxy)] (f jon) + [%add-server `@tas`id [name url headers %.y (get-opt-tas 'oauth-provider') get-mode (get-opt-str 'schema-url')]] + :: + %'remove-server' + [%remove-server `@tas`((ot ~[id+so]) jon)] + :: + %'update-server' + =/ f + %- ot + :~ id+so name+so url+so + headers+(ar (ot ~[key+so value+so])) + enabled+bo + == + =/ [id=@t name=@t url=@t headers=(list header:mcp-proxy) enabled=?] (f jon) + [%update-server `@tas`id [name url headers enabled (get-opt-tas 'oauth-provider') get-mode (get-opt-str 'schema-url')]] + :: + %'toggle-server' + [%toggle-server `@tas`((ot ~[id+so]) jon)] + :: + %'refresh-spec' + [%refresh-spec `@tas`((ot ~[id+so]) jon)] + :: + %'set-tool-filter' + [%set-tool-filter `@tas`((ot ~[id+so]) jon) [%block ~]] + :: + %'clear-tool-filter' + [%clear-tool-filter `@tas`((ot ~[id+so]) jon)] + :: + %'login-server' + [%login-server `@tas`((ot ~[id+so]) jon)] + :: + %'set-client-key' + [%set-client-key ((ot ~[key+so]) jon)] + :: + %'regenerate-client-key' + [%regenerate-client-key ~] + :: + %'clear-client-key' + [%clear-client-key ~] + :: + %'set-internal-token' + [%set-internal-token ((ot ~[token+so]) jon)] + == + -- +++ grad %noun +-- diff --git a/desk/mar/mcp-proxy-update.hoon b/desk/mar/mcp-proxy-update.hoon new file mode 100644 index 0000000..c5e839d --- /dev/null +++ b/desk/mar/mcp-proxy-update.hoon @@ -0,0 +1,12 @@ +/- mcp-proxy +|_ upd=update:mcp-proxy +++ grow + |% + ++ noun upd + -- +++ grab + |% + ++ noun update:mcp-proxy + -- +++ grad %noun +-- diff --git a/desk/mar/oauth-action.hoon b/desk/mar/oauth-action.hoon new file mode 100644 index 0000000..b0af1a9 --- /dev/null +++ b/desk/mar/oauth-action.hoon @@ -0,0 +1,80 @@ +:: oauth-action: mark for oauth agent actions +:: +/- oauth +|_ act=action:oauth +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action:oauth + ++ json + |= jon=^json + ^- action:oauth + =, dejs:format + =/ typ=@t ((ot ~[action+so]) jon) + ?+ typ !! + %'add-provider' + =/ f + %- ot + :~ id+so + auth-url+so + token-url+so + revoke-url+(mu so) + client-id+so + client-secret+so + redirect-uri+so + scopes+so + == + =/ $: id=@t + auth-url=@t + token-url=@t + revoke-url=(unit @t) + client-id=@t + client-secret=@t + redirect-uri=@t + scopes=@t + == + (f jon) + [%add-provider `@tas`id [auth-url token-url revoke-url client-id client-secret redirect-uri scopes]] + :: + %'remove-provider' + [%remove-provider `@tas`((ot ~[id+so]) jon)] + :: + %'update-provider' + =/ f + %- ot + :~ id+so + auth-url+so + token-url+so + revoke-url+(mu so) + client-id+so + client-secret+so + redirect-uri+so + scopes+so + == + =/ $: id=@t + auth-url=@t + token-url=@t + revoke-url=(unit @t) + client-id=@t + client-secret=@t + redirect-uri=@t + scopes=@t + == + (f jon) + [%update-provider `@tas`id [auth-url token-url revoke-url client-id client-secret redirect-uri scopes]] + :: + %'connect' + [%connect `@tas`((ot ~[id+so]) jon)] + :: + %'disconnect' + [%disconnect `@tas`((ot ~[id+so]) jon)] + :: + %'revoke' + [%revoke `@tas`((ot ~[id+so]) jon)] + == + -- +++ grad %noun +-- diff --git a/desk/mar/oauth-update.hoon b/desk/mar/oauth-update.hoon new file mode 100644 index 0000000..2e6af71 --- /dev/null +++ b/desk/mar/oauth-update.hoon @@ -0,0 +1,14 @@ +:: oauth-update: mark for oauth grant updates +:: +/- oauth +|_ upd=update:oauth +++ grow + |% + ++ noun upd + -- +++ grab + |% + ++ noun update:oauth + -- +++ grad %noun +-- diff --git a/desk/sur/docket.hoon b/desk/sur/docket.hoon new file mode 100644 index 0000000..0edcb9e --- /dev/null +++ b/desk/sur/docket.hoon @@ -0,0 +1,80 @@ +|% +:: ++$ version + [major=@ud minor=@ud patch=@ud] +:: ++$ glob (map path mime) +:: ++$ url cord +:: $glob-location: How to retrieve a glob +:: ++$ glob-reference + [hash=@uvH location=glob-location] +:: ++$ glob-location + $% [%http =url] + [%ames =ship] + == +:: $href: Where a tile links to +:: ++$ href + $% [%glob base=term =glob-reference] + [%site =path] + == +:: $chad: State of a docket +:: ++$ chad + $~ [%install ~] + $% :: Done + [%glob =glob] + [%site ~] + :: Waiting + [%install ~] + [%suspend glob=(unit glob)] + :: Error + [%hung err=cord] + == +:: +:: $charge: A realized $docket +:: ++$ charge + $: =docket + =chad + == +:: +:: $clause: A key and value, as part of a docket +:: ++$ clause + $% [%title title=@t] + [%info info=@t] + [%color color=@ux] + [%glob-http url=cord hash=@uvH] + [%glob-ames =ship hash=@uvH] + [%image =url] + [%site =path] + [%base base=term] + [%version =version] + [%website website=url] + [%license license=cord] + == +:: +:: $docket: A description of JS bundles for a desk +:: ++$ docket + $: %1 + title=@t + info=@t + color=@ux + =href + image=(unit url) + =version + website=url + license=cord + == +:: ++$ charge-update + $% [%initial initial=(map desk charge)] + [%add-charge =desk =charge] + [%del-charge =desk] + == +-- diff --git a/desk/sur/mcp-proxy.hoon b/desk/sur/mcp-proxy.hoon new file mode 100644 index 0000000..c8a3f81 --- /dev/null +++ b/desk/sur/mcp-proxy.hoon @@ -0,0 +1,103 @@ +:: mcp-proxy: types for MCP server proxy +:: +|% ++$ server-id @tas +:: ++$ header [key=@t value=@t] +:: ++$ server-mode ?(%proxy %openapi) +:: ++$ tool-filter + $: mode=?(%allow %block) + tools=(set @t) + == +:: +:: old types for state migration ++$ mcp-server-0 + $: name=@t + url=@t + headers=(list header) + enabled=? + == +:: ++$ mcp-server-1 + $: name=@t + url=@t + headers=(list header) + enabled=? + oauth-provider=(unit @tas) + == +:: ++$ mcp-server + $: name=@t + url=@t + headers=(list header) + enabled=? + oauth-provider=(unit @tas) + mode=server-mode + schema-url=(unit @t) + == +:: ++$ state-0 + $: %0 + servers=(map server-id mcp-server-0) + server-order=(list server-id) + == +:: ++$ state-1 + $: %1 + servers=(map server-id mcp-server-1) + server-order=(list server-id) + == +:: ++$ state-2 + $: %2 + servers=(map server-id mcp-server) + server-order=(list server-id) + == +:: ++$ state-3 + $: %3 + servers=(map server-id mcp-server) + server-order=(list server-id) + tool-filters=(map server-id tool-filter) + == +:: ++$ state-4 + $: %4 + servers=(map server-id mcp-server) + server-order=(list server-id) + tool-filters=(map server-id tool-filter) + client-key=(unit @t) :: user-set x-api-key for /apps/mcp/mcp + internal-token=(unit @t) :: mcp-server's auto-gen token (cached) + == +:: ++$ versioned-state + $% state-0 + state-1 + state-2 + state-3 + state-4 + == +:: ++$ action + $% [%add-server id=server-id =mcp-server] + [%remove-server id=server-id] + [%update-server id=server-id =mcp-server] + [%toggle-server id=server-id] + [%login-server id=server-id] + [%refresh-spec id=server-id] + [%set-tool-filter id=server-id =tool-filter] + [%clear-tool-filter id=server-id] + [%set-client-key key=@t] + [%regenerate-client-key ~] + [%clear-client-key ~] + [%set-internal-token token=@t] + == +:: ++$ update + $% [%server-added id=server-id =mcp-server] + [%server-removed id=server-id] + [%server-updated id=server-id =mcp-server] + == +-- diff --git a/desk/sur/oauth.hoon b/desk/sur/oauth.hoon new file mode 100644 index 0000000..bb862a7 --- /dev/null +++ b/desk/sur/oauth.hoon @@ -0,0 +1,83 @@ +:: oauth: types for OAuth 2.0 + PKCE agent +:: +|% ++$ provider-id @tas +:: +:: $provider-config: OAuth provider endpoint configuration +:: +:: .auth-url: authorization endpoint +:: .token-url: token exchange endpoint +:: .revoke-url: optional revocation endpoint +:: .client-id: OAuth client identifier +:: .client-secret: OAuth client secret +:: .redirect-uri: callback URL on this ship +:: .scopes: space-separated scope string +:: ++$ provider-config + $: auth-url=@t + token-url=@t + revoke-url=(unit @t) + client-id=@t + client-secret=@t + redirect-uri=@t + scopes=@t + == +:: +:: $grant: stored OAuth token set +:: +:: .access-token: bearer token +:: .refresh-token: optional refresh token +:: .token-type: e.g. "Bearer" +:: .expires-at: absolute expiry time (or ~) +:: .scopes: granted scopes +:: .provider-id: which provider this grant is for +:: ++$ grant + $: access-token=@t + refresh-token=(unit @t) + token-type=@t + expires-at=(unit @da) + scopes=@t + =provider-id + == +:: +:: $pending-auth: in-flight OAuth authorization +:: +:: .state: random state parameter (lookup key) +:: .verifier: PKCE code verifier +:: .provider-id: which provider +:: ++$ pending-auth + $: state=@t + verifier=@t + =provider-id + == +:: ++$ state-0 + $: %0 + providers=(map provider-id provider-config) + grants=(map provider-id grant) + pending=(map @t pending-auth) + == +:: ++$ versioned-state + $% state-0 + == +:: ++$ action + $% [%add-provider id=provider-id config=provider-config] + [%remove-provider id=provider-id] + [%update-provider id=provider-id config=provider-config] + [%connect id=provider-id] + [%disconnect id=provider-id] + [%revoke id=provider-id] + [%force-refresh id=provider-id] + == +:: ++$ update + $% [%grant-added =provider-id =grant] + [%grant-removed =provider-id] + [%grant-refreshed =provider-id =grant] + [%token-expired =provider-id] + == +-- diff --git a/desk/web/css/app.css b/desk/web/css/app.css new file mode 100644 index 0000000..9c08db1 --- /dev/null +++ b/desk/web/css/app.css @@ -0,0 +1,1217 @@ +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + MCP PROXY · OPERATOR STATION + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +:root { + /* palette */ + --ink: #0a0d14; + --ink-2: #0e121b; + --ink-3: #141924; + --ink-4: #1b2230; + --line: #252c3c; + --line-2: #313a4e; + --line-hi: #46526b; + + --paper: #e6e4dc; + --paper-dim: #a9a99f; + --paper-mute: #6d6e68; + --paper-fade: #4a4b47; + + --saffron: #ffb454; + --saffron-hi: #ffcb7a; + --saffron-dim: #b97d2d; + --mint: #95e6cb; + --coral: #ff6b6b; + --iris: #a78bfa; + + /* type */ + --serif: 'Instrument Serif', 'Times New Roman', serif; + --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Menlo', monospace; + + /* spacing scale */ + --s-0: 0.25rem; + --s-1: 0.5rem; + --s-2: 0.75rem; + --s-3: 1rem; + --s-4: 1.5rem; + --s-5: 2rem; + --s-6: 3rem; + --s-7: 4.5rem; + --s-8: 6rem; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { background: var(--ink); color: var(--paper); } + +body { + font-family: var(--mono); + font-size: 14px; + font-weight: 400; + line-height: 1.55; + letter-spacing: 0.01em; + min-height: 100vh; + position: relative; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ─── background atmosphere ──────────────────────────────────────────── */ + +.bg-grid { + position: fixed; inset: 0; + background-image: + linear-gradient(to right, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255,255,255,0.018) 1px, transparent 1px); + background-size: 64px 64px; + pointer-events: none; + z-index: 0; + mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 85%); + -webkit-mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 85%); +} + +.bg-glow { + position: fixed; + top: -30vh; left: 50%; + width: 120vw; height: 100vh; + transform: translateX(-50%); + background: + radial-gradient(ellipse 60% 50% at 50% 30%, + rgba(255, 180, 84, 0.10) 0%, + rgba(255, 180, 84, 0.04) 30%, + transparent 70%); + pointer-events: none; + z-index: 0; +} + +/* ─── console layout ─────────────────────────────────────────────────── */ + +.console { + position: relative; + z-index: 1; + max-width: 1080px; + margin: 0 auto; + padding: var(--s-6) var(--s-5) var(--s-7); +} + +@media (min-width: 860px) { + .console { padding: var(--s-7) var(--s-6) var(--s-8); } +} + +/* ═══ MASTHEAD ═══════════════════════════════════════════════════════ */ + +.masthead { + padding-bottom: var(--s-6); + margin-bottom: var(--s-7); + border-bottom: 1px solid var(--line); + position: relative; +} + +.masthead::after { + content: ''; + position: absolute; + left: 0; bottom: -1px; + width: 120px; height: 1px; + background: var(--saffron); +} + +.masthead-rail { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--paper-mute); + margin-bottom: var(--s-5); +} + +.brand { + display: inline-flex; + align-items: center; + gap: var(--s-1); + color: var(--paper); +} + +.brand-mark { + color: var(--saffron); + font-size: 14px; + line-height: 1; + transform: translateY(-1px); +} + +.brand-text { font-weight: 500; letter-spacing: 0.16em; } + +.masthead-meta { display: inline-flex; gap: 0.6rem; } +.meta-div { color: var(--paper-fade); } + +.display-title { + font-family: var(--serif); + font-weight: 400; + line-height: 0.92; + letter-spacing: -0.01em; + margin-bottom: var(--s-4); + color: var(--paper); + font-size: clamp(3rem, 8vw, 6.25rem); +} + +.display-line { display: block; } +.display-line-italic { + font-style: italic; + color: var(--saffron); + padding-left: 1.2em; +} + +.masthead-blurb { + max-width: 90ch; + font-size: 14px; + line-height: 1.7; + color: var(--paper-dim); + margin-bottom: var(--s-5); +} + +/* status strip */ + +.status-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + border: 1px solid var(--line); + border-radius: 2px; + background: linear-gradient(180deg, var(--ink-2) 0%, var(--ink) 100%); + overflow: hidden; +} + +.status-cell { + padding: var(--s-2) var(--s-3); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.status-cell:last-child { border-right: none; } + +.status-label { + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--paper-mute); +} + +.status-value { + font-size: 13px; + color: var(--paper); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + +/* ═══ PANEL / SECTION ════════════════════════════════════════════════ */ + +.panel { + padding: 0; + position: relative; +} + +.section-intro { + max-width: 64ch; + font-size: 13px; + line-height: 1.7; + color: var(--paper-dim); + margin-bottom: var(--s-5); +} + +/* ═══ ENDPOINT SHEET ═════════════════════════════════════════════════ */ + +.endpoint-sheet { + background: var(--ink-2); + border: 1px solid var(--line); + border-radius: 3px; + overflow: hidden; + box-shadow: 0 1px 0 rgba(255,255,255,0.02) inset; +} + +.sheet-row { + display: grid; + grid-template-columns: 96px 1fr; + align-items: stretch; + border-bottom: 1px solid var(--line); + min-height: 56px; +} + +.sheet-row:last-child { border-bottom: none; } + +.sheet-label { + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--paper-mute); + padding: var(--s-3); + display: flex; + align-items: center; + border-right: 1px solid var(--line); + background: var(--ink); +} + +.sheet-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + padding: var(--s-3); + min-width: 0; +} + +.sheet-value code { + font-family: var(--mono); + font-size: 13px; + color: var(--saffron); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: all; + flex: 1; + min-width: 0; +} + +.sheet-value code.is-unset { + color: var(--paper-mute); + font-style: italic; +} + +.key-btns { + display: inline-flex; + gap: 4px; + flex-shrink: 0; +} + +.copy-btn.danger-btn:hover { + color: var(--paper); + background: var(--coral); + border-color: var(--coral); +} + +.inline-code { + font-family: var(--mono); + font-size: 0.92em; + padding: 1px 6px; + background: var(--ink); + border: 1px solid var(--line); + border-radius: 2px; + color: var(--saffron); +} + +.sheet-divider { + height: 1px; + background: var(--line); + margin: 0; +} + +.sheet-command { + padding: var(--s-3); +} + +.sheet-command .sheet-label { + padding: 0 0 var(--s-2) 0; + border-right: none; + background: transparent; +} + +.cmd-wrap { + position: relative; + background: var(--ink); + border: 1px solid var(--line); + border-radius: 2px; +} + +.cmd-block { + font-family: var(--mono); + font-size: 12px; + color: var(--paper-dim); + padding: var(--s-3) var(--s-4) var(--s-3) var(--s-3); + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + overflow-x: auto; +} + +.cmd-copy { + position: absolute; + top: var(--s-1); + right: var(--s-1); +} + +/* ─── copy button ────────────────────────────────────────────────────── */ + +.copy-btn { + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.14em; + color: var(--paper-mute); + background: transparent; + border: 1px solid var(--line-2); + border-radius: 2px; + padding: 6px 10px; + cursor: pointer; + text-transform: uppercase; + transition: all 0.15s ease; + flex-shrink: 0; + white-space: nowrap; +} + +.copy-btn:hover { + color: var(--ink); + background: var(--saffron); + border-color: var(--saffron); +} + +.copy-btn.copied { + color: var(--mint); + border-color: var(--mint); +} + +/* ═══ SECTION TABS ═══════════════════════════════════════════════════ */ + +.section-tabs { + display: flex; + gap: 0; + border: 1px solid var(--line); + border-radius: 3px; + background: linear-gradient(180deg, var(--ink-2) 0%, var(--ink) 100%); + margin-bottom: var(--s-5); + overflow: hidden; + position: relative; +} + +.section-tab { + flex: 1; + display: flex; + align-items: baseline; + gap: 10px; + padding: 16px 22px; + background: transparent; + border: none; + border-right: 1px solid var(--line); + border-bottom: 2px solid transparent; + color: var(--paper-mute); + cursor: pointer; + text-align: left; + font-family: var(--mono); + text-transform: none; + letter-spacing: 0; + transition: all 0.18s ease; + min-width: 0; +} + +.section-tab:last-child { border-right: none; } + +.section-tab:hover { + color: var(--paper-dim); + background: rgba(255, 180, 84, 0.02); +} + +.section-tab.is-active { + color: var(--paper); + background: var(--ink-3); + border-bottom-color: var(--saffron); +} + +.section-tab-num { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.14em; + color: var(--saffron-dim); + font-weight: 500; + transition: color 0.18s; +} + +.section-tab.is-active .section-tab-num { + color: var(--saffron); +} + +.section-tab-label { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; + color: inherit; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.section-tab-count { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.08em; + color: var(--paper-mute); + padding: 2px 7px; + border: 1px solid var(--line-2); + border-radius: 2px; + line-height: 1.4; + font-variant-numeric: tabular-nums; + transition: all 0.18s; +} + +.section-tab.is-active .section-tab-count { + color: var(--saffron); + border-color: var(--saffron-dim); + background: rgba(255, 180, 84, 0.06); +} + +@media (max-width: 640px) { + .section-tab { flex-direction: column; gap: 4px; padding: 12px 10px; align-items: flex-start; } +} + +/* section pane visibility */ +.section-pane { display: none; } +.section-pane.is-active { display: block; animation: fade-in 0.3s cubic-bezier(0.22, 1, 0.36, 1); } + +@keyframes fade-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ═══ FORMS / DRAWER ═════════════════════════════════════════════════ */ + +.drawer { + background: var(--ink-2); + border: 1px solid var(--line); + border-radius: 3px; + margin-bottom: var(--s-4); + transition: border-color 0.2s; +} + +.drawer[open] { border-color: var(--line-2); } + +.drawer-handle { + list-style: none; + padding: var(--s-3) var(--s-4); + display: flex; + align-items: center; + gap: var(--s-2); + cursor: pointer; + color: var(--paper-dim); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + user-select: none; + transition: color 0.15s; +} + +.drawer-handle::-webkit-details-marker { display: none; } +.drawer-handle:hover { color: var(--paper); } + +.drawer-plus { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; height: 22px; + border: 1px solid var(--line-2); + border-radius: 50%; + color: var(--saffron); + font-size: 14px; + font-weight: 400; + line-height: 1; + transition: transform 0.25s ease, background 0.2s; +} + +.drawer[open] .drawer-plus { + transform: rotate(45deg); + background: var(--ink-3); +} + +.form-sheet { + padding: 0 var(--s-4) var(--s-4); + border-top: 1px solid var(--line); + padding-top: var(--s-4); + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s-3); +} + +@media (max-width: 640px) { + .form-grid { grid-template-columns: 1fr; } + .status-strip { grid-template-columns: 1fr 1fr; } + .status-cell:nth-child(2) { border-right: none; } + .status-cell:nth-child(1), .status-cell:nth-child(2) { border-bottom: 1px solid var(--line); } +} + +.field { display: flex; flex-direction: column; min-width: 0; } +.field-full { grid-column: 1 / -1; } + +.field-label { + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--paper-mute); + margin-bottom: var(--s-1); +} + +.field input, +.field select, +.field-input { + font-family: var(--mono); + font-size: 13px; + color: var(--paper); + background: var(--ink); + border: 1px solid var(--line); + border-radius: 2px; + padding: 11px 12px; + width: 100%; + transition: border-color 0.15s, background 0.15s; +} + +.field input::placeholder { color: var(--paper-fade); } + +.field input:focus, +.field select:focus, +.field-input:focus { + outline: none; + border-color: var(--saffron); + background: var(--ink-3); +} + +.field select { cursor: pointer; } + +.field-hint { + font-size: 10px; + letter-spacing: 0.04em; + color: var(--paper-mute); + margin-top: var(--s-1); + font-style: italic; +} + +.label-tag { + display: inline-block; + margin-left: 6px; + padding: 2px 6px; + border: 1px solid rgba(149, 230, 203, 0.3); + border-radius: 2px; + color: var(--mint); + background: rgba(149, 230, 203, 0.06); + font-size: 8px; + letter-spacing: 0.14em; + font-style: normal; + font-weight: 500; +} + +.form-section { + border-top: 1px dashed var(--line); + padding-top: var(--s-3); + margin-top: var(--s-1); + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.form-section-label { + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--paper-mute); +} + +.header-list { display: flex; flex-direction: column; gap: var(--s-1); } + +.header-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--s-1); +} + +.header-row input { + font-family: var(--mono); + font-size: 12px; + color: var(--paper); + background: var(--ink); + border: 1px solid var(--line); + border-radius: 2px; + padding: 9px 10px; + min-width: 0; +} + +.header-row input:focus { outline: none; border-color: var(--saffron); } +.header-row input::placeholder { color: var(--paper-fade); } + +.header-row .icon-btn { width: 36px; } + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--s-2); + margin-top: var(--s-2); +} + +/* ─── buttons ────────────────────────────────────────────────────────── */ + +.primary-btn, +.ghost-btn, +.icon-btn, +.row-btn, +button { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + border-radius: 2px; + transition: all 0.15s ease; +} + +.primary-btn { + background: var(--saffron); + color: var(--ink); + border: 1px solid var(--saffron); + padding: 11px 22px; + font-weight: 600; +} + +.primary-btn:hover { + background: var(--saffron-hi); + border-color: var(--saffron-hi); + transform: translateY(-1px); +} + +.ghost-btn { + background: transparent; + color: var(--paper-dim); + border: 1px dashed var(--line-2); + padding: 9px 14px; + align-self: flex-start; +} + +.ghost-btn:hover { + color: var(--saffron); + border-color: var(--saffron-dim); + border-style: solid; +} + +.icon-btn { + background: var(--ink); + color: var(--paper-mute); + border: 1px solid var(--line); + width: 36px; + height: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.icon-btn:hover { + color: var(--coral); + border-color: var(--coral); +} + +.row-btn { + background: transparent; + color: var(--paper-dim); + border: 1px solid var(--line-2); + padding: 7px 14px; + font-size: 10px; +} + +.row-btn:hover { + color: var(--paper); + border-color: var(--line-hi); + background: var(--ink-3); +} + +.row-btn.accent { + color: var(--saffron); + border-color: var(--saffron-dim); +} +.row-btn.accent:hover { + color: var(--ink); + background: var(--saffron); + border-color: var(--saffron); +} + +.row-btn.danger { + color: var(--coral); + border-color: rgba(255, 107, 107, 0.3); +} +.row-btn.danger:hover { + color: var(--paper); + background: var(--coral); + border-color: var(--coral); +} + +/* ═══ SERVER LIST ════════════════════════════════════════════════════ */ + +.server-list, +.oauth-list { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.server-card, +.oauth-card { + background: var(--ink-2); + border: 1px solid var(--line); + border-radius: 3px; + padding: var(--s-4) var(--s-4) 0; + transition: border-color 0.2s; + position: relative; +} + +.server-card:hover, +.oauth-card:hover { + border-color: var(--line-2); +} + +.server-card.editing, +.oauth-card.editing { + border-color: var(--saffron-dim); +} + +.server-card.disabled { opacity: 0.55; } + +.card-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--s-3); + padding-bottom: var(--s-3); + border-bottom: 1px solid var(--line); +} + +.card-identity { min-width: 0; flex: 1; } + +.card-name { + font-family: var(--mono); + font-weight: 500; + font-size: 17px; + line-height: 1.2; + color: var(--paper); + letter-spacing: 0.01em; + margin-bottom: 6px; + word-break: break-word; +} + +.card-id { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--paper-mute); + text-transform: lowercase; + display: inline-flex; + align-items: baseline; + gap: 6px; +} + +.card-id::before { + content: 'id'; + color: var(--saffron-dim); + font-size: 9px; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 2px 6px; + border: 1px solid var(--line-2); + border-radius: 2px; + line-height: 1; +} + +.card-sub { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--paper-mute); + text-transform: lowercase; +} + +.card-badges { + display: inline-flex; + gap: var(--s-1); + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; +} + +.badge { + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + padding: 4px 8px; + border-radius: 2px; + font-weight: 500; + border: 1px solid transparent; + white-space: nowrap; +} + +.badge.enabled { + color: var(--mint); + border-color: rgba(149, 230, 203, 0.3); + background: rgba(149, 230, 203, 0.05); +} + +.badge.disabled { + color: var(--paper-mute); + border-color: var(--line-2); +} + +.badge.mode-proxy { + color: var(--paper-dim); + border-color: var(--line-2); +} + +.badge.mode-openapi { + color: var(--iris); + border-color: rgba(167, 139, 250, 0.3); + background: rgba(167, 139, 250, 0.05); +} + +.badge.built-in { + color: var(--mint); + border-color: rgba(149, 230, 203, 0.4); + background: rgba(149, 230, 203, 0.07); +} + +.server-card.built-in { + border-left: 2px solid var(--mint); +} + +.badge.connected { + color: var(--mint); + border-color: rgba(149, 230, 203, 0.3); + background: rgba(149, 230, 203, 0.05); +} + +.badge.disconnected { + color: var(--paper-mute); + border-color: var(--line-2); +} + +.badge.cached { + color: var(--saffron); + border-color: rgba(255, 180, 84, 0.3); + background: rgba(255, 180, 84, 0.05); +} + +.badge.stale { + color: var(--coral); + border-color: rgba(255, 107, 107, 0.3); +} + +.card-meta { + padding: var(--s-3) 0; + display: flex; + flex-direction: column; + gap: var(--s-2); + font-size: 12px; + border-bottom: 1px solid var(--line); +} + +.meta-row { + display: grid; + grid-template-columns: 84px 1fr; + align-items: baseline; + gap: var(--s-2); + min-width: 0; +} + +.meta-key { + font-size: 9px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--paper-mute); +} + +.meta-val { + font-family: var(--mono); + font-size: 12px; + color: var(--paper-dim); + word-break: break-all; + min-width: 0; +} + +.meta-val.accent { color: var(--saffron); } + +.meta-val .tag { + display: inline-block; + padding: 2px 7px; + background: var(--ink); + border: 1px solid var(--line-2); + border-radius: 2px; + font-size: 10px; + color: var(--paper-dim); + margin-right: 4px; + margin-bottom: 4px; +} + +.card-actions { + display: flex; + gap: var(--s-1); + flex-wrap: wrap; + padding: var(--s-3) 0; +} + +/* ─── tool panel (legible!) ──────────────────────────────────────────── */ + +.tool-panel { + border-top: 1px solid var(--line); + margin: 0 calc(-1 * var(--s-4)); + padding: var(--s-3) var(--s-4) var(--s-4); + background: var(--ink); +} + +.tool-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + padding-bottom: var(--s-2); + margin-bottom: var(--s-2); + border-bottom: 1px solid var(--line); + flex-wrap: wrap; +} + +.tool-count { + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--paper-mute); +} + +.tool-count strong { + color: var(--saffron); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.tool-actions { display: inline-flex; gap: var(--s-1); } + +.tool-list { + max-height: 380px; + overflow-y: auto; + margin: 0 calc(-1 * var(--s-3)); + scrollbar-width: thin; + scrollbar-color: var(--line-2) transparent; +} + +.tool-list::-webkit-scrollbar { width: 6px; } +.tool-list::-webkit-scrollbar-track { background: transparent; } +.tool-list::-webkit-scrollbar-thumb { + background: var(--line-2); + border-radius: 3px; +} + +.tool-row { + display: flex; + align-items: flex-start; + gap: var(--s-2); + padding: var(--s-2) var(--s-3); + border-bottom: 1px solid var(--line); + transition: background 0.12s; + cursor: pointer; +} + +.tool-row:last-child { border-bottom: none; } +.tool-row:hover { background: var(--ink-2); } + +.tool-row.blocked { opacity: 0.4; } +.tool-row.blocked .tool-row-name { text-decoration: line-through; } + +.tool-checkbox { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1px solid var(--line-hi); + border-radius: 2px; + background: var(--ink); + cursor: pointer; + margin-top: 3px; + flex-shrink: 0; + position: relative; + transition: all 0.15s; +} + +.tool-checkbox:checked { + background: var(--saffron); + border-color: var(--saffron); +} + +.tool-checkbox:checked::after { + content: ''; + position: absolute; + left: 3px; top: 0px; + width: 4px; height: 8px; + border: solid var(--ink); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.tool-row-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.tool-row-name { + font-family: var(--mono); + font-size: 13px; + color: var(--paper); + font-weight: 500; + word-break: break-all; +} + +.tool-row-desc { + font-family: var(--mono); + font-size: 11px; + line-height: 1.55; + color: var(--paper-mute); +} + +/* ─── inline edit form ───────────────────────────────────────────────── */ + +.edit-form { + padding: var(--s-3) 0 var(--s-4); + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.edit-form .form-grid { + grid-template-columns: 1fr 1fr; +} + +@media (max-width: 640px) { + .edit-form .form-grid { grid-template-columns: 1fr; } +} + +/* ═══ OAUTH CARD ═════════════════════════════════════════════════════ */ + +.oauth-card .card-identity { display: flex; flex-direction: column; gap: 4px; } + +/* ═══ EMPTY STATES ═══════════════════════════════════════════════════ */ + +.empty { + border: 1px dashed var(--line-2); + border-radius: 3px; + padding: var(--s-6) var(--s-4); + text-align: center; + color: var(--paper-mute); + font-size: 12px; + letter-spacing: 0.05em; + background: + repeating-linear-gradient( + -45deg, + transparent 0, transparent 10px, + rgba(255,255,255,0.012) 10px, rgba(255,255,255,0.012) 20px + ); +} + +.empty::before { + content: '◇'; + display: block; + font-size: 18px; + color: var(--saffron-dim); + margin-bottom: var(--s-2); +} + +.empty em { + display: block; + margin-top: var(--s-1); + font-family: var(--serif); + font-style: italic; + font-size: 16px; + color: var(--paper-dim); +} + +/* ═══ FOOTER ═════════════════════════════════════════════════════════ */ + +.console-foot { + margin-top: var(--s-7); + padding-top: var(--s-4); + border-top: 1px solid var(--line); + display: flex; + align-items: center; + gap: var(--s-2); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--paper-mute); + flex-wrap: wrap; +} + +.foot-brand { color: var(--paper); } +.foot-brand::first-letter { color: var(--saffron); } +.foot-div { color: var(--paper-fade); } +.foot-ship { color: var(--saffron); margin-left: auto; font-variant-numeric: tabular-nums; } + +/* ═══ TOAST ══════════════════════════════════════════════════════════ */ + +.toast { + position: fixed; + bottom: var(--s-4); + left: 50%; + transform: translate(-50%, 20px); + background: var(--paper); + color: var(--ink); + padding: 12px 22px; + border-radius: 2px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 500; + opacity: 0; + transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + z-index: 100; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); +} + +.toast::before { + content: '◆'; + color: var(--saffron-dim); + margin-right: 8px; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +/* ═══ PAGE LOAD REVEAL ═══════════════════════════════════════════════ */ + +.masthead > *, +.section-tabs { + animation: reveal 0.7s cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +.masthead-rail { animation-delay: 0.00s; } +.display-title { animation-delay: 0.08s; } +.masthead-blurb { animation-delay: 0.18s; } +.status-strip { animation-delay: 0.26s; } +.section-tabs { animation-delay: 0.34s; } + +@keyframes reveal { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/desk/web/index.html b/desk/web/index.html new file mode 100644 index 0000000..ba53dd6 --- /dev/null +++ b/desk/web/index.html @@ -0,0 +1,294 @@ + + + + + + URBIT MCP · LLM CONTROL PLANE + + + + + + + + + +
+ + + + + +
+
+
+ + URBIT MCP +
+
+ +

+ Model Context + Protocol gateway +

+ +

+ Aggregated local & remote MCP endpoints with central auth caching +

+ +
+
+ SHIP + +
+
+ UPSTREAMS + +
+
+ OAUTH + +
+
+
+ + + + + + + + + + + +
+

+ One URL for every MCP-compatible client. All configured upstreams are aggregated here. + Requests authenticate with an X-Api-Key header. +

+ +
+
+
URL
+
+ + +
+
+ +
+
API KEY
+
+ — not set — +
+ + + + +
+
+
+ +
+ +
+
CLAUDE CLI
+
+

+            
+          
+
+
+ +
+ + + + + +
+

+ Remote MCP servers and REST APIs reachable through this proxy. + Each can be linked to an OAuth provider for automatic token injection. +

+ +
+ + + + New upstream + +
+
+ + +
+ +
+ + +
+ + + + + +
+ +
+ +
+ +
+ +
+
+
+ +
+
+ + + + + +
+

+ Credentials for upstreams that need authentication. Tokens are stored on your ship + and refreshed automatically before expiry. +

+ +
+ + + + New provider + +
+
+ + +
+ + + +
+ + +
+ +
+ + +
+ + + +
+ +
+
+
+ +
+
+ + + + + +
+ ◢ URBIT MCP  + —— + LLM Control Plane + —— + +
+ +
+ + + + + diff --git a/desk/web/js/api.js b/desk/web/js/api.js new file mode 100644 index 0000000..5046c5a --- /dev/null +++ b/desk/web/js/api.js @@ -0,0 +1,97 @@ +window.McpProxyAPI = { + base: '/apps/mcp/api', + + async get(path) { + var res = await fetch(this.base + path, { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }, + + async post(data) { + var res = await fetch(this.base, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }, + + getServers: function() { return this.get('/servers'); }, + + addServer: function(id, name, url, headers, opts) { + var data = { action: 'add-server', id: id, name: name, url: url, headers: headers }; + if (opts.oauthProvider) data['oauth-provider'] = opts.oauthProvider; + if (opts.mode) data.mode = opts.mode; + if (opts.schemaUrl) data['schema-url'] = opts.schemaUrl; + return this.post(data); + }, + + removeServer: function(id) { + return this.post({ action: 'remove-server', id: id }); + }, + + updateServer: function(id, name, url, headers, enabled, opts) { + var data = { action: 'update-server', id: id, name: name, url: url, headers: headers, enabled: enabled }; + if (opts.oauthProvider) data['oauth-provider'] = opts.oauthProvider; + if (opts.mode) data.mode = opts.mode; + if (opts.schemaUrl) data['schema-url'] = opts.schemaUrl; + return this.post(data); + }, + + toggleServer: function(id) { + return this.post({ action: 'toggle-server', id: id }); + }, + + refreshSpec: function(id) { + return this.post({ action: 'refresh-spec', id: id }); + }, + + getTools: function(id) { + return this.get('/tools/' + id); + }, + + setToolFilter: function(id, mode, tools) { + return this.post({ action: 'set-tool-filter', id: id, mode: mode, tools: tools }); + }, + + clearToolFilter: function(id) { + return this.post({ action: 'clear-tool-filter', id: id }); + }, + + getClientKey: function() { return this.get('/client-key'); }, + setClientKey: function(key) { return this.post({ action: 'set-client-key', key: key }); }, + regenerateClientKey: function() { return this.post({ action: 'regenerate-client-key' }); }, + clearClientKey: function() { return this.post({ action: 'clear-client-key' }); } +}; + +window.OAuthAPI = { + base: '/oauth/api', + + async get(path) { + var res = await fetch(this.base + path, { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }, + + async post(data) { + var res = await fetch(this.base, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }, + + getProviders: function() { return this.get('/providers'); }, + getGrants: function() { return this.get('/grants'); }, + + addProvider: function(data) { return this.post(data); }, + updateProvider: function(data) { return this.post(data); }, + removeProvider: function(id) { return this.post({ action: 'remove-provider', id: id }); }, + connect: function(id) { return this.post({ action: 'connect', id: id }); }, + disconnect: function(id) { return this.post({ action: 'disconnect', id: id }); } +}; diff --git a/desk/web/js/app.js b/desk/web/js/app.js new file mode 100644 index 0000000..21d9e8a --- /dev/null +++ b/desk/web/js/app.js @@ -0,0 +1,777 @@ +var App = { + servers: [], + oauthProviders: [], + editing: null, + editingProvider: null, + openTools: null, + ship: '', + activeSection: 'endpoint', + + init: function() { + this.bindEvents(); + this.initSectionFromHash(); + this.loadAll(); + }, + + initSectionFromHash: function() { + var hash = (window.location.hash || '').replace('#', ''); + if (hash === 'upstreams' || hash === 'oauth' || hash === 'endpoint') { + this.activateSection(hash); + } + }, + + activateSection: function(name) { + this.activeSection = name; + var tabs = document.querySelectorAll('.section-tab'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].classList.toggle('is-active', tabs[i].getAttribute('data-section') === name); + } + var panes = document.querySelectorAll('.section-pane'); + for (var j = 0; j < panes.length; j++) { + panes[j].classList.toggle('is-active', panes[j].getAttribute('data-pane') === name); + } + if (window.history && window.history.replaceState) { + try { window.history.replaceState(null, '', '#' + name); } catch (e) {} + } + }, + + loadAll: function() { + var self = this; + Promise.all([ + McpProxyAPI.getServers(), + OAuthAPI.getProviders().catch(function() { return { providers: [] }; }), + McpProxyAPI.getClientKey().catch(function() { return {}; }) + ]).then(function(results) { + self.ship = results[0].ship || ''; + self.servers = results[0].servers || []; + self.oauthProviders = results[1].providers || []; + self.clientKey = results[2].clientKey || null; + self.updateEndpoint(); + self.updateStats(); + self.render(); + self.renderOAuth(); + self.populateOAuthSelects(); + }).catch(function(e) { + console.error(e); + }); + }, + + updateEndpoint: function() { + var url = window.location.origin + '/apps/mcp/mcp'; + var urlEl = document.getElementById('agg-url'); + var keyEl = document.getElementById('agg-key'); + var exampleEl = document.getElementById('endpoint-example'); + var hasKey = !!this.clientKey; + var key = this.clientKey || ''; + + if (urlEl) urlEl.textContent = url; + if (keyEl) { + keyEl.textContent = hasKey ? key : '— not set —'; + keyEl.classList.toggle('is-unset', !hasKey); + } + if (exampleEl) { + var name = (this.ship || 'mcp').replace(/^~/, '') || 'mcp'; + if (hasKey) { + exampleEl.textContent = + 'claude mcp add --transport http ' + name + ' \\\n ' + url + + ' \\\n --header "X-Api-Key: ' + key + '"'; + } else { + exampleEl.textContent = + '# set an api key below, then run:\n' + + 'claude mcp add --transport http ' + name + ' \\\n ' + url + + ' \\\n --header "X-Api-Key: "'; + } + } + }, + + updateStats: function() { + var shipEl = document.getElementById('stat-ship'); + var upEl = document.getElementById('stat-upstreams'); + var oaEl = document.getElementById('stat-oauth'); + var footShip = document.getElementById('foot-ship'); + var tabUp = document.getElementById('tab-count-upstreams'); + var tabOa = document.getElementById('tab-count-oauth'); + + var enabled = 0; + for (var i = 0; i < this.servers.length; i++) if (this.servers[i].enabled) enabled++; + var connected = 0; + for (var j = 0; j < this.oauthProviders.length; j++) if (this.oauthProviders[j].hasGrant) connected++; + + var shipDisplay = this.ship || '—'; + if (shipEl) shipEl.textContent = shipDisplay; + if (footShip) footShip.textContent = shipDisplay; + if (upEl) upEl.textContent = enabled + ' / ' + this.servers.length + ' active'; + if (oaEl) oaEl.textContent = connected + ' / ' + this.oauthProviders.length + ' linked'; + if (tabUp) tabUp.textContent = this.servers.length; + if (tabOa) tabOa.textContent = this.oauthProviders.length; + }, + + populateOAuthSelects: function() { + var selects = document.querySelectorAll('.oauth-select'); + for (var i = 0; i < selects.length; i++) { + var sel = selects[i]; + var val = sel.dataset.currentValue || sel.value; + sel.innerHTML = ''; + for (var j = 0; j < this.oauthProviders.length; j++) { + var p = this.oauthProviders[j]; + var opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.id + (p.hasGrant ? ' · connected' : ''); + sel.appendChild(opt); + } + if (val) sel.value = val; + } + }, + + bindEvents: function() { + var self = this; + + document.getElementById('add-form').addEventListener('submit', function(e) { + e.preventDefault(); + var form = e.target; + var id = form.elements.id.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/^-+/, '').replace(/-+$/, ''); + var name = form.elements.name.value.trim(); + var url = form.elements.url.value.trim(); + var headers = self.getHeadersFromForm('add'); + var mode = form.elements.mode.value; + var oauthProv = form.elements['oauth-provider'].value || null; + var schemaUrl = form.elements['schema-url'] ? form.elements['schema-url'].value.trim() : ''; + if (!id || !name || (mode !== 'openapi' && !url)) return; + McpProxyAPI.addServer(id, name, url, headers, {mode: mode, oauthProvider: oauthProv, schemaUrl: schemaUrl || null}).then(function() { + form.reset(); + document.getElementById('add-schema-row').style.display = 'none'; + var rows = document.querySelectorAll('#add-headers .header-row'); + for (var i = 0; i < rows.length; i++) rows[i].remove(); + var drawer = form.closest('details'); + if (drawer) drawer.open = false; + self.loadAll(); + self.toast('Upstream committed'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + + document.getElementById('oauth-add-form').addEventListener('submit', function(e) { + e.preventDefault(); + var f = e.target; + var data = { + action: 'add-provider', + id: f.elements['id'].value.trim().toLowerCase(), + 'auth-url': f.elements['auth-url'].value.trim(), + 'token-url': f.elements['token-url'].value.trim(), + 'revoke-url': f.elements['revoke-url'].value.trim() || null, + 'client-id': f.elements['client-id'].value.trim(), + 'client-secret': f.elements['client-secret'].value.trim(), + 'redirect-uri': f.elements['redirect-uri'].value.trim(), + scopes: f.elements['scopes'].value.trim() + }; + OAuthAPI.addProvider(data).then(function() { + f.reset(); + var drawer = f.closest('details'); + if (drawer) drawer.open = false; + self.loadAll(); + self.toast('Provider committed'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + + // delegated clicks: section tabs + copy buttons + document.body.addEventListener('click', function(e) { + var sectionTab = e.target.closest('.section-tab'); + if (sectionTab) { + self.activateSection(sectionTab.getAttribute('data-section')); + return; + } + + var btn = e.target.closest('.copy-btn'); + if (!btn) return; + var targetId = btn.getAttribute('data-copy'); + if (!targetId) return; + var el = document.getElementById(targetId); + if (!el) return; + var text = el.textContent; + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + btn.classList.add('copied'); + var label = btn.querySelector('.copy-btn-label'); + var prev = label ? label.textContent : null; + if (label) label.textContent = 'OK'; + setTimeout(function() { + btn.classList.remove('copied'); + if (label && prev !== null) label.textContent = prev; + }, 1200); + }); + } + }); + + window.addEventListener('hashchange', function() { + self.initSectionFromHash(); + }); + + // api-key management buttons + var regenBtn = document.getElementById('btn-regen-key'); + if (regenBtn) regenBtn.addEventListener('click', function() { + McpProxyAPI.regenerateClientKey().then(function() { + self.loadAll(); + self.toast('API key generated'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + + var setBtn = document.getElementById('btn-set-key'); + if (setBtn) setBtn.addEventListener('click', function() { + var key = prompt('Enter a custom API key:'); + if (!key) return; + key = key.trim(); + if (!key) return; + McpProxyAPI.setClientKey(key).then(function() { + self.loadAll(); + self.toast('API key set'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + + var clearBtn = document.getElementById('btn-clear-key'); + if (clearBtn) clearBtn.addEventListener('click', function() { + if (!confirm('Clear the API key? The proxy endpoint will stop serving until you set a new one.')) return; + McpProxyAPI.clearClientKey().then(function() { + self.loadAll(); + self.toast('API key cleared'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }); + }, + + getHeadersFromForm: function(prefix) { + var container = document.getElementById(prefix + '-headers'); + if (!container) return []; + var rows = container.querySelectorAll('.header-row'); + var headers = []; + for (var i = 0; i < rows.length; i++) { + var key = rows[i].querySelector('.header-key').value.trim(); + var value = rows[i].querySelector('.header-value').value.trim(); + if (key && value) headers.push({ key: key, value: value }); + } + return headers; + }, + + addHeaderRow: function(container, key, value) { + var row = document.createElement('div'); + row.className = 'header-row'; + var k = document.createElement('input'); + k.type = 'text'; k.className = 'header-key'; k.placeholder = 'header-name'; k.value = key || ''; + var v = document.createElement('input'); + v.type = 'text'; v.className = 'header-value'; v.placeholder = 'value'; v.value = value || ''; + var btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'icon-btn'; btn.innerHTML = '×'; + btn.setAttribute('aria-label', 'Remove header'); + btn.addEventListener('click', function() { row.remove(); }); + row.appendChild(k); row.appendChild(v); row.appendChild(btn); + container.appendChild(row); + }, + + toggleSchemaUrl: function(prefix) { + var mode = document.getElementById(prefix + '-mode').value; + var row = document.getElementById(prefix + '-schema-row'); + var hint = document.getElementById(prefix + '-url-hint'); + var urlInput = document.getElementById(prefix + '-url'); + if (row) row.style.display = mode === 'openapi' ? '' : 'none'; + if (hint) hint.textContent = mode === 'openapi' + ? 'API base URL · optional, derived from spec if empty' + : 'Upstream MCP server endpoint'; + if (urlInput) urlInput.required = mode !== 'openapi'; + }, + + editServer: function(id) { + this.editing = id; + this.openTools = null; + this.render(); + this.populateOAuthSelects(); + }, + + cancelEdit: function() { this.editing = null; this.render(); }, + + saveServer: function(id) { + var self = this; + var card = document.getElementById('edit-' + id); + var name = card.querySelector('.edit-name').value.trim(); + var url = card.querySelector('.edit-url').value.trim(); + var headers = this.getHeadersFromForm('edit-h-' + id); + var oauthProv = card.querySelector('.edit-oauth').value || null; + var mode = card.querySelector('.edit-mode').value; + var schemaInput = card.querySelector('.edit-schema-url'); + var schemaUrl = schemaInput ? schemaInput.value.trim() : ''; + var s = this.servers.find(function(x) { return x.id === id; }); + if (!name || (mode !== 'openapi' && !url)) return; + McpProxyAPI.updateServer(id, name, url, headers, s ? s.enabled : true, { + mode: mode, oauthProvider: oauthProv, schemaUrl: schemaUrl || null + }).then(function() { + self.editing = null; + self.loadAll(); + self.toast('Upstream updated'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + refreshSpec: function(id) { + var self = this; + McpProxyAPI.refreshSpec(id).then(function() { + self.toast('Fetching schema…'); + setTimeout(function() { self.loadAll(); }, 3000); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + showTools: function(id) { + var self = this; + this.openTools = id; + McpProxyAPI.getTools(id).then(function(data) { + var tools = data.tools || []; + var s = self.servers.find(function(x) { return x.id === id; }); + var filter = s && s.toolFilter ? s.toolFilter : null; + var blockedSet = new Set(filter && filter.mode === 'block' ? filter.tools : []); + var allowedSet = new Set(filter && filter.mode === 'allow' ? filter.tools : []); + var isAllowMode = filter && filter.mode === 'allow'; + + var active = 0; + for (var k = 0; k < tools.length; k++) { + var blocked = isAllowMode ? !allowedSet.has(tools[k].name) : blockedSet.has(tools[k].name); + if (!blocked) active++; + } + + var html = '
'; + html += '
'; + html += '
' + active + ' / ' + tools.length + ' tools active'; + if (filter) html += ' · ' + filter.mode + ' filter'; + html += '
'; + html += '
' + + '' + + '' + + '' + + '
'; + + html += '
'; + if (tools.length === 0) { + html += '
No tools discoveredcheck schema or connection
'; + } else { + for (var i = 0; i < tools.length; i++) { + var t = tools[i]; + var isBlocked = isAllowMode ? !allowedSet.has(t.name) : blockedSet.has(t.name); + html += ''; + } + } + html += '
'; + + var container = document.getElementById('server-tools-' + id); + if (container) container.innerHTML = html; + }).catch(function(e) { alert('Failed to load tools: ' + e.message); }); + }, + + setAllTools: function(id, enabled) { + var self = this; + if (enabled) { + McpProxyAPI.clearToolFilter(id).then(function() { + self.loadAll(); + self.showTools(id); + }); + } else { + McpProxyAPI.getTools(id).then(function(data) { + var tools = (data.tools || []).map(function(t) { return t.name; }); + McpProxyAPI.setToolFilter(id, 'block', tools).then(function() { + self.loadAll(); + self.showTools(id); + }); + }); + } + }, + + hideTools: function(id) { + this.openTools = null; + var container = document.getElementById('server-tools-' + id); + if (container) container.innerHTML = ''; + }, + + toggleTool: function(serverId, toolName, enabled) { + var self = this; + var s = this.servers.find(function(x) { return x.id === serverId; }); + var filter = s && s.toolFilter ? s.toolFilter : { mode: 'block', tools: [] }; + var toolSet = new Set(filter.tools || []); + + if (filter.mode === 'block') { + if (enabled) toolSet.delete(toolName); else toolSet.add(toolName); + } else { + if (enabled) toolSet.add(toolName); else toolSet.delete(toolName); + } + + var tools = Array.from(toolSet); + var done = function() { self.loadAll(); self.showTools(serverId); }; + if (tools.length === 0) { + McpProxyAPI.clearToolFilter(serverId).then(done); + } else { + McpProxyAPI.setToolFilter(serverId, filter.mode, tools).then(done); + } + }, + + toggleServer: function(id) { + var self = this; + McpProxyAPI.toggleServer(id).then(function() { self.loadAll(); }) + .catch(function(e) { alert('Failed: ' + e.message); }); + }, + + removeServer: function(id) { + var self = this; + if (!confirm('Remove upstream "' + id + '"?')) return; + McpProxyAPI.removeServer(id).then(function() { + self.loadAll(); + self.toast('Upstream removed'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + editProvider: function(id) { + this.editingProvider = id; + this.renderOAuth(); + }, + + cancelProviderEdit: function() { + this.editingProvider = null; + this.renderOAuth(); + }, + + saveProvider: function(id) { + var self = this; + var card = document.getElementById('edit-provider-' + id); + if (!card) return; + var secretInput = card.querySelector('.edit-p-secret'); + var data = { + action: 'update-provider', + id: id, + 'auth-url': card.querySelector('.edit-p-auth-url').value.trim(), + 'token-url': card.querySelector('.edit-p-token-url').value.trim(), + 'revoke-url': card.querySelector('.edit-p-revoke-url').value.trim() || null, + 'client-id': card.querySelector('.edit-p-client-id').value.trim(), + 'client-secret': secretInput.value, // empty = preserve on backend + 'redirect-uri': card.querySelector('.edit-p-redirect').value.trim(), + scopes: card.querySelector('.edit-p-scopes').value.trim() + }; + OAuthAPI.updateProvider(data).then(function() { + self.editingProvider = null; + self.loadAll(); + self.toast('Provider updated'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + connectProvider: function(id) { + var self = this; + OAuthAPI.connect(id).then(function(data) { + if (data && data.url) { + window.open(data.url, '_blank'); + self.toast('Authorize in new tab, then reload'); + } + }).catch(function(e) { alert('Connect failed: ' + e.message); }); + }, + + disconnectProvider: function(id) { + var self = this; + OAuthAPI.disconnect(id).then(function() { + self.loadAll(); + self.toast('Disconnected'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + removeProvider: function(id) { + var self = this; + if (!confirm('Remove provider "' + id + '"?')) return; + OAuthAPI.removeProvider(id).then(function() { + self.loadAll(); + self.toast('Provider removed'); + }).catch(function(e) { alert('Failed: ' + e.message); }); + }, + + copyUrl: function(text) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + this.toast('Copied'); + } + }, + + toast: function(msg) { + var el = document.getElementById('toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'toast'; + el.className = 'toast'; + document.body.appendChild(el); + } + el.textContent = msg; + el.classList.add('show'); + clearTimeout(this._toastTimer); + this._toastTimer = setTimeout(function() { el.classList.remove('show'); }, 2200); + }, + + renderEditCard: function(s) { + var isOpenapi = s.mode === 'openapi'; + var headersHtml = '
'; + if (s.headers) { + for (var j = 0; j < s.headers.length; j++) { + var h = s.headers[j]; + headersHtml += '
' + + '' + + '' + + '' + + '
'; + } + } + headersHtml += '
'; + + var oauthAttr = ' data-current-value="' + this.esc(s.oauthProvider || '') + '"'; + + return '' + + '
' + + '
' + + '
' + + '
Editing · ' + this.esc(s.name) + '
' + + '
' + this.esc(s.id) + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '' + + headersHtml + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + }, + + render: function() { + var container = document.getElementById('servers'); + if (this.servers.length === 0) { + container.innerHTML = + '
NO UPSTREAMS CONFIGURED' + + 'add your first MCP server or OpenAPI endpoint above
'; + return; + } + + var html = ''; + for (var i = 0; i < this.servers.length; i++) { + var s = this.servers[i]; + if (this.editing === s.id) { html += this.renderEditCard(s); continue; } + + var isOpenapi = s.mode === 'openapi'; + var isBuiltIn = this.ship && s.id === this.ship.replace(/^~/, ''); + var cardClass = 'server-card' + (s.enabled ? '' : ' disabled') + (isBuiltIn ? ' built-in' : ''); + + // badges + var badges = ''; + if (isBuiltIn) badges += 'built-in'; + badges += '' + (isOpenapi ? 'openapi' : 'proxy') + ''; + badges += '' + (s.enabled ? 'enabled' : 'disabled') + ''; + + // meta rows + var meta = ''; + if (s.url) { + meta += '
URL
' + this.esc(s.url) + '
'; + } + if (isOpenapi && s.schemaUrl) { + var specBadge = s.hasCachedSpec + ? 'cached' + : 'not cached'; + meta += '
SCHEMA
' + + this.esc(s.schemaUrl) + '   ' + specBadge + '
'; + } + if (s.headers && s.headers.length > 0) { + var tags = ''; + for (var j = 0; j < s.headers.length; j++) { + tags += '' + this.esc(s.headers[j].key) + ''; + } + meta += '
HEADERS
' + tags + '
'; + } + if (s.oauthProvider) { + var prov = this.oauthProviders.find(function(p) { return p.id === s.oauthProvider; }); + var oauthBadge = prov && prov.hasGrant + ? '' + this.esc(s.oauthProvider) + ' · connected' + : '' + this.esc(s.oauthProvider) + ' · not connected'; + meta += '
OAUTH
' + oauthBadge + '
'; + } + + if (!meta) { + meta = '
STATUS
awaiting configuration
'; + } + + html += '
' + + '
' + + '
' + + '
' + this.esc(s.name) + '
' + + '
' + this.esc(s.id) + '
' + + '
' + + '
' + badges + '
' + + '
' + + '
' + meta + '
' + + '
' + + '' + + '' + + (isOpenapi ? '' : '') + + '' + + '' + + '
' + + '
' + + '
'; + } + + container.innerHTML = html; + + // reopen tool panel if it was showing + if (this.openTools) { + var stillHere = this.servers.find(function(x) { return x.id === App.openTools; }); + if (stillHere) this.showTools(this.openTools); + else this.openTools = null; + } + + // set OAuth dropdown value in edit card + if (this.editing) { + var s2 = this.servers.find(function(x) { return x.id === App.editing; }); + if (s2) { + var sel = document.querySelector('#edit-' + s2.id + ' .edit-oauth'); + if (sel) sel.value = s2.oauthProvider || ''; + } + } + }, + + renderProviderEdit: function(p) { + var secretLabel = p.hasSecret + ? 'CLIENT SECRET ● SAVED' + : 'CLIENT SECRET'; + var secretPlaceholder = p.hasSecret ? '●●●●●●●● (leave blank to keep)' : 'client secret'; + var secretHint = p.hasSecret + ? 'stored on your ship — type a new value to replace it' + : 'required'; + return '' + + '
' + + '
' + + '
' + + '
Editing · ' + this.esc(p.id) + '
' + + '
' + this.esc(p.id) + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + }, + + renderOAuth: function() { + var container = document.getElementById('oauth-providers'); + if (this.oauthProviders.length === 0) { + container.innerHTML = + '
NO PROVIDERS CONFIGURED' + + 'link an upstream to OAuth by adding a provider above
'; + return; + } + + // count which upstreams use each provider + var usageMap = {}; + for (var i = 0; i < this.servers.length; i++) { + var srv = this.servers[i]; + if (srv.oauthProvider) { + usageMap[srv.oauthProvider] = (usageMap[srv.oauthProvider] || []).concat([srv.id]); + } + } + + var html = ''; + for (var k = 0; k < this.oauthProviders.length; k++) { + var p = this.oauthProviders[k]; + if (this.editingProvider === p.id) { html += this.renderProviderEdit(p); continue; } + var connected = p.hasGrant; + var users = usageMap[p.id] || []; + var usersHtml = users.length > 0 + ? users.map(function(u) { return '' + App.esc(u) + ''; }).join('') + : 'unused'; + + html += '
' + + '
' + + '
' + + '
' + this.esc(p.id) + '
' + + '
oauth2 + pkce
' + + '
' + + '
' + + '' + + (connected ? 'connected' : 'disconnected') + + '' + + '
' + + '
' + + '
' + + (p.authUrl ? '
AUTH URL
' + this.esc(p.authUrl) + '
' : '') + + (p.scopes ? '
SCOPES
' + this.esc(p.scopes) + '
' : '') + + '
USED BY
' + usersHtml + '
' + + '
' + + '
' + + '' + + (connected + ? '' + : '') + + '' + + '
' + + '
'; + } + container.innerHTML = html; + }, + + esc: function(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +}; + +document.addEventListener('DOMContentLoaded', function() { App.init(); });