Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,61 @@ Use `host.routes`, `host.tools`, `host.events`, and `host.jobs` to contribute
behaviour. Do not import internal harness paths; only the package root and its
`/wire` export are stable public APIs.

## Install a declared remote MCP connector

An external product plugin can install a remote connector through the MCP
plugin's public port. Declare `uses: ['mcpConnectors']`; do not import MCP
storage, routes, OAuth helpers, or call the machine's own HTTP API.

```ts
import { definePlugin, type McpConnectorPort } from '@hoshi/harness'

export default [
definePlugin({
name: 'example-packs',
description: 'Installs example integrations.',
uses: ['mcpConnectors'],
setup(host) {
const connectors: McpConnectorPort | undefined = host.ports.mcpConnectors
if (!connectors) return

void connectors.install({
name: 'example',
transport: 'http',
url: 'https://mcp.example.com/',
vaultHeaders: {
Authorization: { key: 'EXAMPLE_TOKEN', template: 'Bearer {{secret}}' },
},
})
},
}),
]
```

The port accepts remote `http` or `sse` transports at `http:`/`https:` URLs;
it has no stdio or literal-header path. A vault binding names a machine vault
key and one of two templates: `{{secret}}` or `Bearer {{secret}}`. The binding,
not the secret value, is stored; its value is read only immediately before the MCP
plugin dials the server. If your pack format calls its placeholder
`{{credential}}`, translate it to `{{secret}}` and map the declared credential
to its machine vault key before calling the port.

To begin machine-owned OAuth DCR from one of your authenticated routes, pass
that incoming request's headers. Discovery, registration, PKCE, callback,
refresh and tokens remain inside the MCP plugin:

```ts
const result = await host.ports.mcpConnectors?.beginDcrAuthorization({
name: 'example',
request: { headers: event.node.req.headers },
scopes: ['read:documents'],
})
// return or redirect to result?.url; never handle a token in this plugin.
```

`status(name)` returns only name, connection state, OAuth state and tool count.
It never returns an endpoint, vault binding, token, or secret value.

## Describe what the machine can do

`capability` is the public, stable identity of the unit your plugin adds. Its
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ export type {

export { RouteTable, RouteConflictError, ownedBy } from './http/router.js'
export type { RouteRecord, Method } from './http/router.js'
export type {
McpConnectorDeclaration,
McpConnectorPort,
McpConnectorSnapshot,
McpConnectorStatus,
McpConnectorTransport,
McpConnectorVaultHeader,
McpDcrAuthorizationRequest,
} from './mcp-connectors.js'

/**
*
Expand Down
4 changes: 4 additions & 0 deletions src/kernel/host-ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MachineEvent } from './events.js'
import type { PendingAsk, PermissionResolution } from './permissions.js'
import type { Provider } from './providers.js'
import type { ShellResult } from '@openharness/core'
import type { McpConnectorPort } from '../mcp-connectors.js'

/**
* ── What the kernel asks of its host ─────────────────────────────────────────
Expand Down Expand Up @@ -123,6 +124,9 @@ export interface UnattendedContext {
}

export interface KernelPorts extends UnattendedContext {
/** Declared remote MCP connectors. Answered by the MCP plugin so product
* plugins never import its storage or route internals. */
mcpConnectors?: McpConnectorPort
/** Extra standing instructions supplied by installed plugins.
*
* The harness owns the order in which a turn is assembled, but not every
Expand Down
50 changes: 50 additions & 0 deletions src/mcp-connectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Public, tokenless contract for a plugin that installs a declared remote MCP
* connector. The MCP plugin owns storage, discovery and credentials; a product
* plugin only names an endpoint and vault bindings.
*/
export type McpConnectorTransport = 'http' | 'sse'

/** One request header whose value is assembled on the machine when it dials.
* `template` is either `{{secret}}` or `Bearer {{secret}}`. For example:
* `{ key: 'GITHUB_TOKEN', template: 'Bearer {{secret}}' }`.
*/
export interface McpConnectorVaultHeader {
/** Name of a machine-vault key. Its value never crosses this public seam. */
key: string
/** `{{secret}}` or `Bearer {{secret}}`. */
template: string
}

export interface McpConnectorDeclaration {
name: string
url: string
transport: McpConnectorTransport
/** Header name -> vault binding. Literal credentials are intentionally absent. */
vaultHeaders?: Record<string, McpConnectorVaultHeader>
}

export type McpConnectorAuthStatus = 'authenticated' | 'needs-auth' | 'expired' | null
export type McpConnectorStatus = 'connected' | 'unreachable' | 'disabled'

/** Deliberately excludes URL, header bindings and all credential material. */
export interface McpConnectorSnapshot {
name: string
status: McpConnectorStatus
auth: McpConnectorAuthStatus
toolCount: number
}

export interface McpDcrAuthorizationRequest {
name: string
/** The request that initiated authorization, used only to derive the machine callback origin. */
request: { headers: Record<string, string | string[] | undefined> }
scopes?: string[]
}

/** The MCP plugin's narrow service for external product plugins. */
export interface McpConnectorPort {
install(input: McpConnectorDeclaration): Promise<void>
beginDcrAuthorization(input: McpDcrAuthorizationRequest): Promise<{ url: string }>
status(name: string): Promise<McpConnectorSnapshot | null>
}
33 changes: 33 additions & 0 deletions src/plugins/mcp/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import mcp from './index.js'
import type { KernelPorts } from '../../kernel/host-ports.js'
import type { PluginHost } from '../define.js'

describe('the public MCP connector port', () => {
it('is provided by the MCP plugin without exposing its routes or storage', () => {
let provided: KernelPorts | undefined
const routes = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn(), all: vi.fn() }
const host: PluginHost = {
tools: { add: vi.fn() },
routes,
events: { publish: vi.fn(), onConnect: vi.fn() },
jobs: { every: vi.fn(), once: vi.fn() },
log: { info: vi.fn(), error: vi.fn() },
unattended: {},
platform: () => null,
ports: {},
provide: (extension) => {
provided = typeof extension === 'function' ? extension({}) : extension
},
}

mcp.setup(host, undefined)

expect(provided?.mcpConnectors).toEqual({
install: expect.any(Function),
beginDcrAuthorization: expect.any(Function),
status: expect.any(Function),
})
expect(routes.post).toHaveBeenCalledWith('/mcp/:name/oauth', expect.any(Function))
})
})
13 changes: 13 additions & 0 deletions src/plugins/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import oauthCallback from './mcp.oauth.callback.get.js'
import startOauth from './mcp.name.oauth.post.js'
import disconnectOauth from './mcp.name.oauth.delete.js'
import { refreshExpiringTokens } from './oauth-connect.js'
import { connectorStatus, installRemoteServer } from './servers.js'
import { beginDcrAuthorization } from './mcp.name.oauth.post.js'

/**
* ── MCP connectors ───────────────────────────────────────────────────────────
Expand All @@ -34,6 +36,17 @@ export default definePlugin({
capability: { id: 'mcp.connectors', title: 'MCP connectors', description: 'Third-party MCP servers as tools' },

setup(host) {
/** The product-facing connector service. It is a port, not routes called
* back through localhost, so an external plugin stays independent of this
* plugin's storage and keeps its request context for OAuth redirects. */
host.provide((current) => ({
...current,
mcpConnectors: {
install: installRemoteServer,
beginDcrAuthorization,
status: connectorStatus,
},
}))
/**
*
* Built per turn rather than cached: a connector added a moment ago has to
Expand Down
47 changes: 37 additions & 10 deletions src/plugins/mcp/mcp.name.oauth.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineEventHandler, getRouterParam } from 'h3'
import { randomBytes } from 'node:crypto'
import { apiError, requireAuth } from '../../kernel/index.js'
import { listServers } from './servers.js'
import type { McpDcrAuthorizationRequest } from '../../mcp-connectors.js'
import {
authorizationUrl,
createPkce,
Expand All @@ -25,14 +26,12 @@ import { beginFlow, redirectUri } from './oauth-flow.js'
* business holding one.
*
**/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const name = getRouterParam(event, 'name')!
const server = (await listServers()).find((entry) => entry.name === name)
if (!server) apiError(404, 'mcp.notFound', 'No connector by that name.')
export async function beginDcrAuthorization(input: McpDcrAuthorizationRequest): Promise<{ url: string }> {
const server = (await listServers()).find((entry) => entry.name === input.name)
if (!server) throw new McpAuthorizationError('notFound', 'No connector by that name.')
const config = server.config
if (config.type === 'stdio') {
apiError(400, 'mcp.oauthLocal', 'A local connector runs as a command and has nothing to authorize.')
throw new McpAuthorizationError('local', 'A local connector runs as a command and has nothing to authorize.')
}

try {
Expand All @@ -50,32 +49,60 @@ export default defineEventHandler(async (event) => {
**/
let registration = await registrationFor(metadata.issuer)
if (!registration) {
registration = await registerClient(metadata, redirectUri(event), 'Hoshi machine')
registration = await registerClient(metadata, redirectUri(input.request.headers), 'Hoshi machine')
await rememberRegistration(metadata.issuer, registration)
}

const pkce = createPkce()
const state = randomBytes(24).toString('base64url')
beginFlow(state, {
name,
name: input.name,
issuer: metadata.issuer,
resource: resource.resource,
metadata,
verifier: pkce.verifier,
})

const scopes = input.scopes?.filter((scope) => typeof scope === 'string' && scope.trim()).join(' ')
return {
url: authorizationUrl({
metadata,
clientId: registration.clientId,
redirectUri: redirectUri(event),
redirectUri: redirectUri(input.request.headers),
state,
challenge: pkce.challenge,
resource: resource.resource,
...(scopes ? { scope: scopes } : {}),
}),
}
} catch (error) {
if (error instanceof OAuthError) apiError(400, 'mcp.oauthFailed', error.message)
if (error instanceof OAuthError) throw new McpAuthorizationError('failed', error.message)
throw error
}
}

export class McpAuthorizationError extends Error {
constructor(
readonly kind: 'notFound' | 'local' | 'failed',
message: string,
) {
super(message)
}
}

export default defineEventHandler(async (event) => {
await requireAuth(event)
try {
return await beginDcrAuthorization({
name: getRouterParam(event, 'name')!,
request: { headers: event.node.req.headers as Record<string, string | string[] | undefined> },
})
} catch (error) {
if (error instanceof McpAuthorizationError) {
const status = error.kind === 'notFound' ? 404 : 400
const code = error.kind === 'notFound' ? 'mcp.notFound' : error.kind === 'local' ? 'mcp.oauthLocal' : 'mcp.oauthFailed'
apiError(status, code, error.message)
}
throw error
}
})
2 changes: 1 addition & 1 deletion src/plugins/mcp/mcp.oauth.callback.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default defineEventHandler(async (event) => {
metadata: flow.metadata,
clientId: registration.clientId,
clientSecret: registration.clientSecret,
redirectUri: redirectUri(event),
redirectUri: redirectUri(event.node.req.headers as Record<string, string | string[] | undefined>),
code,
verifier: flow.verifier,
resource: flow.resource,
Expand Down
9 changes: 7 additions & 2 deletions src/plugins/mcp/oauth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@ export function claimFlow(state: string): PendingFlow | null {
return flow
}

/** A declaration changed while consent was open. Its callback must not attach
* the old endpoint's authorization to the replacement connector. */
export function forgetFlowsFor(name: string): void {
for (const [state, flow] of pending) if (flow.name === name) pending.delete(state)
}

/** The machine's own origin, taken from the request the person's browser is
* making — not from configuration, which is how a redirect URI ends up
* pointing at the wrong host on a machine reached through an ingress. */
export function redirectUri(event: { node: { req: { headers: Record<string, unknown> } } }): string {
const headers = event.node.req.headers
export function redirectUri(headers: Record<string, string | string[] | undefined>): string {
const forwardedProto = String(headers['x-forwarded-proto'] ?? '')
.split(',')[0]
?.trim()
Expand Down
8 changes: 8 additions & 0 deletions src/plugins/mcp/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
registerClient,
revokeToken,
} from './oauth.js'
import { redirectUri } from './oauth-flow.js'

/**
*
Expand Down Expand Up @@ -105,6 +106,13 @@ describe('the well-known paths', () => {
)
expect(protectedResourceMetadataUrl('https://x.test/')).toBe('https://x.test/.well-known/oauth-protected-resource')
})

it('derives the callback from the initiating request headers, not a platform URL', () => {
expect(redirectUri({ host: 'machine.test' })).toBe('https://machine.test/mcp/oauth/callback')
expect(redirectUri({ host: 'internal:4200', 'x-forwarded-host': 'machine.test', 'x-forwarded-proto': 'https' })).toBe(
'https://machine.test/mcp/oauth/callback',
)
})
})

describe('a full authorization, against a server', () => {
Expand Down
Loading
Loading