Skip to main content
Glama

portal-mcp

Tests Version Python MCP License Claude Code plugin

Turn any website or customer portal into MCP tools, without an open API and without writing code. portal-mcp is a Claude Code plugin and a local MCP server. You point it at a site, log in yourself if the site needs it, and the plugin explores the pages in a real browser, learns how they load their data, and lets you choose which tools Claude gets: list your invoices, download a PDF, look something up, fill in a form.

  • Works with logged-in services, customer portals and public sites.

  • You log in yourself, in a visible browser window. The runtime never reads or stores your password.

  • Each service is described by a YAML manifest. The runtime turns the manifest into MCP tools.

  • Sessions are stored encrypted. The encryption key lives in your system keyring.

  • Write actions run in two steps and only after you approve them yourself, in a dialog or a local confirmation page. The model cannot approve its own actions.

  • Every write action is logged in a local audit log.

Status: version 0.9.7. Runtime with read, file, write and binding tools and human approval; logins through other hosts, BankID and OpenID Connect apps; data hosts called with the app's own token; HTML pages as read tools; file uploads; public sites without login; encrypted sessions and several profiles per service; a recorder that explores pages by itself and captures writes without sending them; self_test, desktop notifications, the portal-mcp-creator skill and the slash commands. Tested against a local mock service.

Requirements

  • A computer with a graphical desktop: macOS, Windows, or Linux with a desktop environment.

  • uv to run the runtime.

  • Google Chrome (preferred), or Playwright's Chromium: uv run playwright install chromium.

Related MCP server: guarded-browser-mcp

Install

Pick the way that matches where you use Claude. Everything except the last option installs the whole plugin: the runtime, the portal-mcp-creator skill and the /portal-mcp:* commands.

Where you use Claude

Way to install

Claude Code in a terminal

Slash commands or the command line

The Claude desktop app, Code tab

The command line or the app's plugin settings

Claude Code in VS Code or JetBrains

The command line

A team or a project

Project settings

Working on the plugin itself

A local clone

Another MCP client, or the desktop app's chat

The runtime alone (tools only)

While the repository is private, every way fetches it with your local git credentials, so only accounts with access can install it. With the GitHub CLI: gh auth login, then gh auth setup-git.

Claude Code in a terminal

/plugin marketplace add markstrom/portal-mcp
/plugin install portal-mcp@portal-mcp

/plugin opens an interactive dialog, so it works only in Claude Code in a terminal.

The command line

Run these in any terminal. Claude Code in a terminal, the desktop app and the IDE extensions share the same installation, so this works for all of them:

claude plugin marketplace add markstrom/portal-mcp
claude plugin install portal-mcp@portal-mcp

Restart the app or Claude Code afterwards so the plugin is loaded.

The Claude desktop app, Code tab

The Code tab cannot run /plugin. Either use the command line and restart the app, or add the marketplace markstrom/portal-mcp in the app's plugin settings and install portal-mcp from there.

A team or a project

Add this to .claude/settings.json in a project. Everyone who opens the project in Claude Code and trusts it is offered the plugin:

{
  "extraKnownMarketplaces": {
    "portal-mcp": { "source": { "source": "github", "repo": "markstrom/portal-mcp" } }
  },
  "enabledPlugins": { "portal-mcp@portal-mcp": true }
}

A local clone, for development

git clone https://github.com/markstrom/portal-mcp.git
claude plugin marketplace add ./portal-mcp
claude plugin install portal-mcp@portal-mcp

Or load it for one session only, without installing: claude --plugin-dir ./portal-mcp.

The runtime alone, as an MCP server

Other MCP clients can use the runtime directly. You then get the tools of the services you have already added, but not the skill or the /portal-mcp:* commands, so new services are added from Claude Code. Clone the repository first, then point the client at it.

For Claude Code without the plugin:

claude mcp add portal-mcp -- uv run --quiet --project /path/to/portal-mcp portal-mcp-runtime

For the Claude desktop app's chat, or any client that reads an mcpServers file (in the desktop app: Settings, Developer, Edit config):

{
  "mcpServers": {
    "portal-mcp": {
      "command": "uv",
      "args": ["run", "--quiet", "--project", "/path/to/portal-mcp", "portal-mcp-runtime"]
    }
  }
}

Clients that connect over HTTP can use uv run portal-mcp-runtime --http and the address http://127.0.0.1:8765/mcp instead.

Check, update and remove

  • Check: /portal-mcp:list shows the loaded services. If the tools do not show up, restart the app or Claude Code.

  • Update: claude plugin update portal-mcp@portal-mcp, then restart. In a terminal session, /plugin has the same option.

  • Remove: claude plugin uninstall portal-mcp@portal-mcp and claude plugin marketplace remove portal-mcp. Your services, sessions and downloads stay in the data directory (see Where your data lives) until you delete them.

Commands

Command

What it does

/portal-mcp:add <url>

Add a service, logged in or public. It starts at once: you log in if the service needs it, the plugin visits and explores the pages itself, then shows a catalog where you choose the tools.

/portal-mcp:update <service>

Explore more parts of a service, or redo discovery for tools that stopped working.

/portal-mcp:list

Services, profiles, tools and session status.

/portal-mcp:test <service> [profile]

Run self_test and suggest an update if answers changed.

/portal-mcp:logout <service> [profile]

Log out at the service and delete the local session.

How it fits together

flowchart LR
    client["MCP client<br/>(Claude Code)"] -- "stdio or localhost HTTP" --> runtime["portal-mcp runtime"]
    runtime --> manifests["services/*.yaml"]
    runtime --> store["sessions/ (encrypted)"]
    runtime -- "HTTP with the session" --> service["The service's web API"]
    runtime -- "opens and watches" --> browser["Visible browser window<br/>(you log in here)"]
    creator["portal-mcp-creator skill"] -- "writes manifests" --> manifests
    creator -- "guided recording" --> browser

The skill creates manifests. The runtime turns them into tools. The browser is only used for logging in and recording, and you control the login.

Public sites work too: with session: {type: none} there is no login, no session and nothing stored, and tools call the site without cookies. If a site has both public pages and a login, /portal-mcp:add asks once which part you want. For public sites the skill suggests a low rate limit and caching of reads.

While recording, the plugin explores each page itself: it clicks tabs, expandable sections and "show more" buttons, because many single-page apps load their data only after such a click. It never clicks controls that mean an action (log out, delete, pay, send, save, confirm and similar), submit buttons, unlabeled controls or downloads, and every write a click causes is blocked and captured, never sent.

Login and a tool call

sequenceDiagram
    participant C as MCP client
    participant R as Runtime
    participant K as Session store
    participant B as Browser window
    participant S as Service
    C->>R: call example_list_invoices
    R->>K: load session
    alt no valid session
        R->>B: open login_url, notify the user
        B->>S: user logs in
        R->>S: poll session check until 200
        R->>K: save encrypted session
        R-->>C: "log in and call again" (if not done within ~20 s)
    end
    R->>S: GET /api/invoices with session
    S-->>R: JSON
    R->>R: pick fields, redact, cache
    R-->>C: result

Creating a service (skill pipeline)

flowchart TD
    A[Scope: which service and functions] --> B[Recon without login]
    B --> C[Guided recording<br/>you log in, GET only after login]
    C --> D[Analysis: path templates, fields, pagination]
    C --> W[Forms submitted by you<br/>writes captured, not sent]
    W --> D
    D --> E[Catalog: read / file / write / binding, flags]
    E --> F{You choose tools}
    F --> G[Generate manifest, you approve]
    G --> H[Verify tools against recording]
    H --> I[Delete recordings, commit manifest]

Write actions and approval

sequenceDiagram
    participant M as Model (via MCP client)
    participant R as Runtime
    participant U as You
    participant S as Service
    M->>R: example_prepare_pay_invoice(amount, invoice_id)
    R->>R: check schema and limits
    R-->>M: summary + one-time code (5 min)
    M->>R: example_confirm_pay_invoice(code)
    alt client supports elicitation
        R->>U: dialog in the client with the summary
    else
        R->>U: local confirmation page in the browser<br/>(secret address, never shown to the model)
    end
    U-->>R: Bekräfta / Avbryt
    alt approved
        R->>S: POST with the session
        S-->>R: response
        R->>R: audit log: approved, executed
        R-->>M: done
    else declined or no answer
        R-->>M: declined, or "waiting for approval, call again"
    end

The summary you approve is built by the runtime from the real values, not written by the model. Codes are one-time and expire after five minutes. Limits are checked when the action is prepared and again when it is confirmed.

Running it somewhere else

By default the runtime runs on your own computer, started by Claude Code over stdio, with no network connection. It can also run on another computer of yours that has a desktop, reached through HTTP mode and an SSH tunnel; you then log in through screen sharing. A server without a desktop cannot show the login window, so only public sites can work there. HTTP mode has no authentication, so it must listen on 127.0.0.1 only.

See docs/remote.md for the steps, the security rules and the known limits.

Where your data lives

Data is kept outside the plugin, so updates never remove it. The location follows your OS conventions (platformdirs):

<user data dir>/portal-mcp/
  services/<service>.yaml     manifests
  services/<service>.schemas.json  self_test baselines (structure only)
  profiles/<service>/<profile>/  browser profiles
  sessions/                   encrypted sessions
  recordings/                 temporary recordings, deleted after a manifest is written
  uploads/                    files that upload tools may send (nothing else can be uploaded)
  audit.log                   every write action: time, service, tool, call and response

Set PORTAL_MCP_HOME to use another base directory. Downloaded files go to your Downloads folder, or to PORTAL_MCP_DOWNLOADS if set.

Manifest

One file per service, services/<name>.yaml. The file name must match name. A broken manifest only affects its own service.

The runtime watches services/ and sends tools/list_changed when a manifest changes. Some clients still need a restart before new or changed tools show up.

name: example
enabled: true
base_url: https://app.example.com
login_url: https://app.example.com/login
profiles: [default]
session:
  type: cookies               # cookies | bearer
  bearer_source: null         # for bearer: "localStorage:<key>" or "sessionStorage:<key>"
  csrf:                       # optional
    header: x-csrf-token      # send the token in this header, and/or
    body_field: null          # put it in this body field
    from_cookie: csrf         # take it from this cookie, or
    from_page: null           # from a page: {path: /form, name: <hidden input or meta name>}
  check: /api/session         # 200 = logged in, 401 = not logged in
  logout: /api/logout         # optional, called with POST
login_hosts: [login.example.com]  # optional: other hosts the login goes through
data_hosts: [api.example.net]     # optional: APIs on other hosts the pages load data from
asset_hosts: [cdn.example.net]    # optional: CDNs for scripts, styles, fonts and images
data_auth:                        # optional: the token tools on data hosts send
  source: "sessionStorage:oidc.user:*"  # where the service's own app keeps it ("*" matches anything)
  field: access_token             # a field of a JSON value, if the value is JSON
  page: /cases                    # a page that makes the app get its token
login_hosts_after_login: blocked  # or oidc: login host open after login for OIDC endpoints only
rate_limit_per_minute: 30
cache_seconds: 30             # caches read results
redact: [items.owner.national_id]
tools:
  - name: list_invoices
    kind: read                # read | file | write | binding
    description: List invoices with date, amount and status.
    method: GET
    path: /api/invoices
    params:
      type: object
      properties:
        page: {type: integer}
    pick: [items.id, items.date, items.amount, items.status]
  - name: download_invoice_pdf
    kind: file
    description: Download the PDF for one invoice.
    method: GET
    path: /api/invoices/{invoice_id}/pdf
    params:
      type: object
      properties:
        invoice_id: {type: string}
      required: [invoice_id]
  - name: pay_invoice
    kind: binding             # binding: payments, bids, orders, signatures
    description: Pay an invoice.
    method: POST
    path: /api/invoices/{invoice_id}/pay
    params:
      type: object
      properties:
        invoice_id: {type: string}
      required: [invoice_id]
    body_encoding: json       # json | form
    body_schema:
      type: object
      properties:
        amount: {type: number}
      required: [amount]
    body_constants: {}        # fixed body fields, for example hidden form inputs
    limits:                   # required for binding tools
      max: {amount: 5000}     # also: min, allowed (list of values), max_length
    summary: "Pay {amount} for invoice {invoice_id}"
  - name: send_message
    kind: write
    messaging: true           # sends free text to other people: off by default
    enabled: true             # needed to turn a messaging tool on
    method: POST
    path: /api/messages
    body_schema:
      type: object
      properties:
        to: {type: string}
        text: {type: string}
      required: [to, text]

Read and file tools are prefixed with the service name, for example example_list_invoices. Write and binding tools become example_prepare_pay_invoice and example_confirm_pay_invoice. Every tool except confirm_ also takes an optional profile argument.

Common tools:

  • list_services: services, profiles, session status, tools, hidden tools, load errors and the data paths.

  • forget_session(service, profile?): delete the local session without contacting the service.

  • logout(service, profile?): call the logout endpoint and delete the local session; without a profile, all profiles.

  • self_test(service, profile?, update_baseline?): run the read tools and compare each answer's structure with the saved baseline.

Self test

self_test runs every exposed read tool. Tools with required parameters need test_args in the manifest, for example test_args: {page: 1}; without them they are skipped. Write and file tools are never run.

The first run saves the structure of each answer (field names and types, never values) in services/<service>.schemas.json. Later runs report removed or changed fields as changed, and new fields as notes. Run it with update_baseline: true to accept a new structure.

An answer that is empty, or whose items are all empty after pick, gets status empty and is never saved as a baseline; unmatched_pick lists pick paths that matched no item. When a list's items come in more than one shape, the saved structure keeps one variant per shape.

Pick paths can have fallbacks: item.name|name uses the first path that exists in each item and writes the value at the first path, so items of different shapes come out in one shape.

Profiles

A service can have several profiles, for example two people in a household or a private and a business account: profiles: [default, business]. Each profile has its own browser profile and its own encrypted session. Every tool takes an optional profile argument; the first call with a new profile opens a login window for it.

Notifications

The runtime shows a desktop notification when a login is waiting, when a login is done, and when a confirmation page is waiting for you. macOS uses osascript, Linux uses notify-send when it is installed, and Windows uses desktop-notifier. Set PORTAL_MCP_NO_NOTIFY=1 to turn them off.

Logging in

When the creator skill records a service, every recording starts with a clean, temporary browser profile, checks that the login is really verified (only a 200 from session.check counts, and a check that answers 200 without a login is refused), and only then lists the service's menu links for you to choose from. If a service rejects a request because its headers are too large (usually too many cookies), the plugin says so, shows the cookies for the service's host by name and count only, and suggests a clean profile; forget_session with browser_profile: true deletes the runtime's browser profile.

Some pages are single-page apps that run their own OpenID Connect client after the login: they load a library from a CDN and get their own token from the login host. Declare the CDN in asset_hosts (static files only: scripts, styles, fonts, images) and set login_hosts_after_login: oidc to keep the login host open after login for the endpoints its discovery document names (POST only to the token endpoint, never the logout endpoint). Both are off by default. The analyzer refuses recordings whose login was not verified, unless --allow-unverified is given.

Some services load their data from an API on another host. List such hosts in data_hosts. When recording, a data host is allowed for GET over https, and writes to it are captured, not sent, just like the service's own host. It never gets the service's cookies. Tools can call a data host when the service's own app authenticates to it with a token: data_auth says where the app keeps that token after login, the runtime opens data_auth.page after you log in, reads the token, stores it encrypted with the session, and sends it as Authorization: Bearer only to declared data hosts, for tools with host set. The recording reports which storage keys hold tokens (key names only) so the skill can fill this in.

HTML pages work as read tools too: response: html with embedded reads JSON that the page carries in a <script> tag (for example script#__NEXT_DATA__ or JSON-LD), and extract takes values with CSS selectors (text, an attribute, lists, and one object per repeated element). The page is parsed as data: no script runs and nothing else is fetched, and only text, attribute values and the selected JSON are returned. The analyzer describes HTML pages with a skeleton that holds no text.

Other tool options: public: true makes a read or file tool call the site without the session and never open a login, for public pages in a service that otherwise needs one. body_encoding: multipart with file_fields uploads files; a file argument must be a file in the uploads folder of the data directory, and the approval shows each file's name and size. File tools may download up to 200 MB; other answers are limited to 10 MB.

While recording, consent managers (cookie banners) may load with GET, so pages that wait for them show their content, and exploring declines the banner; analytics and advertising stay blocked.

Many services log in through another host, for example an identity provider or an account service on its own address. List such hosts in login_hosts, as plain host names. When the creator skill records a service, a login host is allowed only while you log in, only over https, and is blocked after login. The saved session only ever holds cookies for the base_url host. Hosts are never added automatically: if a recording is stopped by an unknown host, the skill asks you whether you trust it.

When a tool needs a session and none is valid, a browser window opens on login_url and a desktop notification asks you to log in. The runtime checks the session check endpoint and captures the session once you are logged in. A tool call waits up to about 20 seconds; if you are not done by then, it tells you to finish in the window and call again. The login continues in the background. Only one login runs at a time.

Development

uv sync
uv run pytest

The tests run against tests/mock_service/, a local fake portal with invented data. Tests that need a browser are skipped if neither Google Chrome nor Playwright's Chromium is installed.

Run the server directly:

uv run portal-mcp-runtime            # stdio (default)
uv run portal-mcp-runtime --http     # streamable HTTP at http://127.0.0.1:8765/mcp

Logs go to stderr only.

Security notes

  • Passwords are never handled by this software. Logging in happens in a window you can see.

  • The browser always runs with Chrome's sandbox on. If it cannot start with the sandbox, the login fails with an error instead of starting without it.

  • Session files are encrypted with a key stored in the system keyring and have mode 0600.

  • The HTTP transport only binds to localhost.

  • Read results can be trimmed with pick and redact before they reach the model.

  • Write actions need a person's approval through MCP elicitation or a local confirmation page. The page is served on 127.0.0.1, its address carries a secret token that the model never sees, and it only accepts clicks from its own origin.

  • Tools that send free text to other people are off unless the manifest turns them on.

  • During recording, writes are stopped in the browser and only their format is saved, unless you explicitly agree to a real action.

  • Set PORTAL_MCP_APPROVAL=browser to always use the confirmation page, even if the client supports elicitation.

License

Apache License 2.0. See LICENSE.

Available Tools

4 tools
forget_sessionA

Delete the local session for a service without contacting the service. With browser_profile, also delete the browser profile (cookies and device memory) so the next login starts clean; the service may then ask to recognize the device again.

Inputs: service (required), profile, browser_profile.

list_servicesA
Read-only

List loaded services, their profiles, session status, tools and load errors.

Inputs: no named parameters.

logoutA

Log out of a service (calls its logout endpoint if configured) and delete the local session. Without a profile, all profiles of the service are logged out.

Inputs: service (required), profile.

self_testB

Run the read tools of a service and compare the structure of each answer with the structure saved when the tool was verified. Saves a baseline for tools that have none. Write and file tools are not run. Set update_baseline to accept the current structure as the new baseline.

Inputs: service (required), profile, update_baseline.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.6/5.0

    Scored across 4 tools

    Disambiguation4/5

    Each tool targets a distinct action: listing state, deleting a local session, logging out remotely plus clearing local state, and verifying tool output structures. forget_session and logout are the closest pair, but the descriptions explicitly contrast them (local-only vs. contacting the service's logout endpoint), so misselection risk is low.

    Naming Consistency4/5

    All names are snake_case and mostly follow a verb_noun pattern (list_services, forget_session, self_test). 'logout' is a bare verb and 'self_test' is a noun-like compound, but the style is uniform enough to be predictable.

    Tool Count4/5

    Four tools is a small but well-scoped surface for a session/service-management server, with no redundant members. It leans slightly thin, since a handful of related operations (login, reload, profile inspection) could plausibly belong here, but nothing feels padded.

    Completeness3/5

    The surface covers observing services, tearing down sessions, and validating tool contracts, but there is no operation to authenticate/start a session, reload a service after config or load errors, or inspect individual tool results beyond the self_test summary. Agents will often need to work around these gaps indirectly through the service's own tools.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      MCP server that lets agents drive your real Chrome browser with existing logins and sessions via an outbound-only WebSocket extension. It exposes Playwright-compatible browser tools for navigation, clicking, typing, and snapshots.
      1
      Apache 2.0
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI agents to drive a self-hosted Chromium through MCP tools for opening pages, reading content, taking screenshots, clicking, filling forms, and triggering final submissions, while a human watches the same browser live and can take over for logins or captchas. Server-side guardrails enforce per-session domain allowlists and denylists, refuse password, payment card, IBAN and one-time-code fields, isolate each token in its own browser context, and route submits, payments and orders through a separate final-action tool that can require human approval.
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables any agent to drive a dedicated Chromium instance through MCP, including opening and reading pages, taking steps, logging in via a password vault, downloading/uploading files, and replaying saved routines. It also gates sensitive actions behind approvals and can use client certificates or one-time card data for official procedures.
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables any MCP-capable AI agent to operate the user's own everyday Chrome browser with all existing login sessions intact, offering 22 tools for reading page/network data, filling forms, clicking, batch multi-step actions, and handing CAPTCHAs or confirmations back to the human — without API keys, debug ports, or launch flags.
      MIT