Skip to content

Blueprints

Add an MCP server

AI-assistedThis page includes both human and AI contributions.

How to make an MCP server importable as a Package: declare it, allow the tools the task needs, give it a credential or log in, register it on a server, and handle a server that can't be reached.

The curated Packages cover common services, and you can write a Package for your own. For a service that has neither yet, or whose Package lacks a feature you need, there is often an MCP server. Declared in a Blueprint, that server becomes a Package. Submilli reads its tools and their JSON schemas and turns them into a TypeScript library with one typed function per tool. A program imports and calls it like any other Package, under the same rules. The Blueprint says which of its tools a program may call, and the credential stays outside the program.

This guide shows you how to make an MCP server importable as a Package. The examples are Playwright’s server, which needs no account, and Linear’s, which takes an API key or an OAuth login. Substitute your server’s URL and tools.

To follow the Playwright example, start its server first:

Terminal window
npx @playwright/mcp@latest --port 8931 --headless --isolated
Terminal window
submilli blueprint init browse
submilli blueprint add-mcp playwright http://localhost:8931/mcp
✓ added mcp server 'playwright' (no auth) to blueprint.yaml
Gated by `mcp.playwright` (deny by default) — set its action to `allow` (optionally `filter: tool == "..."`) to use it.
blueprint.yaml (fragment)
permissions:
main:
- capability: mcp.playwright
action: deny
mcp:
playwright:
url: http://localhost:8931/mcp

The name you give becomes the key in the mcp block, the Package name @mcp/playwright, and the capability mcp.playwright. The server must speak MCP over HTTP. If yours speaks it over standard input and output, put it behind an HTTP endpoint first, as --port does for Playwright’s. Refer to MCP servers for the block’s fields.

The deny rule that add-mcp wrote says nothing default: deny doesn’t, so drop it first. capability remove takes all the rules for a capability, so it goes before the grant. One capability covers the server, and the tool field of a filter picks tools:

Terminal window
submilli blueprint capability remove mcp.playwright
submilli blueprint capability add mcp.playwright \
--filter 'tool == "browser_navigate" or tool == "browser_snapshot" or tool == "browser_click"'
✓ removed 1 rule(s) for 'mcp.playwright' from caller 'main' in blueprint.yaml
✓ added allow mcp.playwright (filter: tool == "browser_navigate" or tool == "browser_snapshot" or tool == "browser_click") to caller 'main' in blueprint.yaml
blueprint.yaml (fragment)
permissions:
main:
- capability: mcp.playwright
filter: tool == "browser_navigate" or tool == "browser_snapshot" or tool == "browser_click"
action: allow

The three tools are allowed and other tools meet the default. To allow the tools whose names start the same way, use glob, as in tool glob "list_*". Don’t write the tool into the capability name, as in mcp.playwright/browser_click, because the Blueprint is refused.

Choose tools by what their arguments can do as well as by their names. A rule sees the tool’s name and nothing else, and browser_navigate asked for a javascript: address runs script in the page as browser_evaluate would. If an allowed tool is that broad, restrict it where the server runs (Playwright’s takes --allowed-origins), or put a Package in front of it that checks the arguments and grant the Package instead.

link.ts
import playwright from "@mcp/playwright";
function main(): string {
playwright.browser_navigate({ url: "https://example.com" });
return playwright.browser_snapshot({}) as string;
}
Terminal window
submilli run --blueprint blueprint.yaml link.ts
### Page
- Page URL: https://example.com/
- Page Title: Example Domain
### Snapshot
```yaml
- generic [ref=e2]:
- heading "Example Domain" [level=1] [ref=e3]
- paragraph [ref=e4]: This domain is for use in documentation examples without needing permission. Avoid use in operations.
- paragraph [ref=e5]:
- link "Learn more" [ref=e6] [cursor=pointer]:
- /url: https://iana.org/domains/example
```

A program’s calls to one server share one MCP session, closed when the program ends. With a server that keeps state, do a task in one program. submilli docs @mcp/playwright lists the server’s tools as declarations. A tool without an output schema returns unknown, so cast the result to the type you expect, as above.

Linear’s server takes an API key as a bearer token. Declare the secret, store the value, and declare the server with it. --authorization-bearer writes the header for you:

Terminal window
submilli blueprint secret add LINEAR_API_KEY --store linear_api_key
submilli secret put linear_api_key
submilli blueprint add-mcp linear https://mcp.linear.app/mcp --authorization-bearer LINEAR_API_KEY
✓ declared secret 'LINEAR_API_KEY' (store: linear_api_key) in blueprint.yaml
Value for 'linear_api_key': [hidden]
Stored secret 'linear_api_key'
✓ added mcp server 'linear' (static header auth) to blueprint.yaml
Gated by `mcp.linear` (deny by default) — set its action to `allow` (optionally `filter: tool == "..."`) to use it.
blueprint.yaml (fragment)
mcp:
linear:
url: https://mcp.linear.app/mcp
headers:
Authorization: Bearer ${secrets.LINEAR_API_KEY}

The credential is added outside the program, and a tool’s arguments never carry it. If users must reach the server as themselves, declare the secret with --harness and write the header yourself with --header 'Authorization: Bearer ${secrets.LINEAR_API_KEY}'. The value then comes from your application when it opens the session.

Allow two of its tools, and read their declarations. docs connects to the server with the key to get them:

Terminal window
submilli blueprint capability remove mcp.linear
submilli blueprint capability add mcp.linear --filter 'tool == "list_teams" or tool == "get_issue"'
submilli docs @mcp/linear --blueprint blueprint.yaml
✓ removed 1 rule(s) for 'mcp.linear' from caller 'main' in blueprint.yaml
✓ added allow mcp.linear (filter: tool == "list_teams" or tool == "get_issue") to caller 'main' in blueprint.yaml
warning: @mcp/linear: 59 tool(s) return unknown: result schemas are unavailable or unrepresentable; consult package docs for signatures
@mcp/linear — MCP server 'linear' (59 tools)
…
/**
* Retrieve detailed information about an issue by ID, including attachments, git branch name, and active Triage Intelligence suggestions when the issue is in triage
* Returns `unknown`; this MCP server did not publish an outputSchema — cast to a declared type (`as T`) after checking the shape.
*/
function get_issue(args: { id: string; includeCustomerNeeds?: boolean; includeRelations?: boolean; includeReleases?: boolean }): unknown;
…

Linear publishes no output schemas, so all its tools return unknown. A program declares the shape it expects and casts:

teams.ts
import linear from "@mcp/linear";
interface Teams {
teams: { id: string; name: string }[];
}
function main(): string {
const result = linear.list_teams({}) as Teams;
return result.teams.map((team) => team.name).join(", ");
}
Terminal window
submilli run --blueprint blueprint.yaml teams.ts
warning: @mcp/linear: 59 tool(s) return unknown: result schemas are unavailable or unrepresentable; consult package docs for signatures
Submilli

A program that calls list_users, which the filter leaves out, is refused before any request leaves:

error: PermissionDeniedError: permission denied: caller=main capability=mcp.linear: policy denied mcp.linear for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop.

Linear also takes an OAuth login, which spares you a key. In a Blueprint that doesn’t declare linear yet, give add-mcp no credential flag. It asks the server whether it requires OAuth and writes auth: type: oauth2 if it does:

Terminal window
submilli blueprint add-mcp linear https://mcp.linear.app/mcp
✓ added mcp server 'linear' (oauth) to blueprint.yaml
Gated by `mcp.linear` (deny by default) — set its action to `allow` (optionally `filter: tool == "..."`) to use it.
Authenticate locally: submilli mcp authenticate linear --blueprint blueprint.yaml
After applying to a server: submilli server mcp authenticate browse linear
blueprint.yaml (fragment)
mcp:
linear:
url: https://mcp.linear.app/mcp
auth:
type: oauth2

Until someone logs in, the Blueprint is PENDING. It still runs programs, without that server:

Terminal window
submilli mcp auth-status --blueprint blueprint.yaml
browse: PENDING
linear oauth — NOT AUTHENTICATED

Log in once:

Terminal window
submilli mcp authenticate linear --blueprint blueprint.yaml
Open this URL in your browser to authorize:
https://mcp.linear.app/authorize?response_type=code&client_id=…
Waiting for the redirect on http://127.0.0.1:8765/callback …
✓ authenticated 'linear' on blueprint 'browse' — blueprint 'browse' is ACTIVE
Terminal window
submilli mcp auth-status --blueprint blueprint.yaml
browse: ACTIVE
linear oauth — authenticated

The same teams.ts runs under the login and answers the same. Discovery found 68 tools this time where the key saw 59, because the server decides what a credential may see.

The credential lands in the local secret store, and deauthenticate forgets it. One login serves all programs run under the Blueprint, and on a server all users’ sessions. Log in as an account that may do what you are willing to let any user’s agent do, and narrow it with the tool filter. If users must act as themselves, use a per-user token in a header.

If the service requires a registered application, as GitHub does, configure a provider for its login host before authenticating:

Terminal window
submilli mcp provider add --match github.com --client-id Iv1.example \
--client-secret '${secrets.GITHUB_CLIENT_SECRET}' --scope repo

If a service refuses a login’s refresh token, programs get McpAuthExpiredError. When the refusal lasts, log in again.

Registered on submilli-server, the same Blueprint (here with Linear declared for OAuth) gives all sessions the same Packages. The server now owns the network it connects from, the store its logins are kept in, and the list of tools it has read. Register it and check its logins:

Terminal window
submilli server blueprint apply blueprint.yaml
submilli server mcp auth-status browse
Added blueprint 'browse'
browse: PENDING
linear oauth — NOT AUTHENTICATED
playwright n/a (no auth)

A program that imports a server with no login yet doesn’t compile, and run-code says which login is missing:

Terminal window
submilli server run-code teams.ts --blueprint browse
warning: @mcp/linear: server unavailable: not authenticated — run `submilli server mcp authenticate`
error: MCP server `linear` is unavailable — `@mcp/linear` is absent from the discovered catalog; check the blueprint's `mcp:` block and discovery warnings

An MCP server is an outbound destination like any other, so the server’s block on private addresses covers it. Started without flags, the server refuses the Playwright example on localhost, and link.ts fails the same way:

warning: @mcp/playwright: server unavailable: blocked by network policy: localhost resolves only to private/loopback IP space; allow-list it on the server with --allow-ip / --allow-localhost / --allow-private

Start it with --allow-localhost for the example, or --allow-ip for an MCP server inside your network. Then log in to Linear on the server. The command runs on your machine, so the browser it opens is yours, and the login lands in the server’s secret store, separate from the local one:

Terminal window
submilli server mcp authenticate browse linear
Open this URL in your browser to authorize:
https://mcp.linear.app/authorize?response_type=code&client_id=…
Waiting for the redirect on http://127.0.0.1:8765/callback …
✓ authenticated 'linear' on blueprint 'browse' — blueprint 'browse' is ACTIVE
Terminal window
submilli server mcp auth-status browse
submilli server run-code teams.ts --blueprint browse
browse: ACTIVE
linear oauth — authenticated
playwright n/a (no auth)
warning: @mcp/linear: 68 tool(s) return unknown: result schemas are unavailable or unrepresentable; consult package docs for signatures
Submilli

submilli server mcp deauthenticate browse linear removes the login. On a server, a provider for a service such as GitHub goes under mcp_oauth in the server’s config file:

server.yaml (fragment)
mcp_oauth:
providers:
- match: github.com
client_id: Iv1.example
client_secret: ${secrets.GITHUB_CLIENT_SECRET}

The server reads an MCP server’s tools the first time a program or a search needs them, and keeps the list until the Blueprint is applied again, a login changes, or the server restarts. After an MCP server gains or loses a tool, apply the Blueprint again.

When discovery can’t reach an MCP server within ten seconds, the network rules block it, or it has no login yet, the Blueprint still works without that Package. Locally, a run warns warning: @mcp/playwright: server unavailable: with the reason. On a server, the same line is in run-code’s output, in the HTTP response’s discovery_warnings list, and as a WARN line in the server’s log. A program that imports the missing Package doesn’t compile:

error: MCP server `playwright` is unavailable — `@mcp/playwright` is absent from the discovered catalog; check the blueprint's `mcp:` block and discovery warnings

Each tool call has sixty seconds, including login and connection. Results come back as text. Images are dropped, and nothing streams.